Compare commits

...

41 Commits

Author SHA1 Message Date
ok ab6d768e3e hel: enc takes any command; gen names in capture; ls-name resolve
enc: drop the ls/ld-only guard — it now runs any parseable command and
encodes the last output line, full parity with pb. ls/ld/gen all emit bare
names in capture mode.

gen: capture mode skips the password table and emits one bare variant name per
row, so 'pb gen', 'enc gen' and 'pb enc gen' compose like ls/ld.

get_password: third fallback resolves by ls-value entry name, so generated
variants (which live only in the ls map under a numeric id, not in db) can be
re-encoded by name.

help (enc/gen/pb/overview) updated; test gen_capture_and_enc_test added.
2026-06-14 18:16:43 +02:00
ok df0730486e Merge github main (gitignore scratch) into hel CLI work 2026-06-14 18:02:00 +02:00
ok 4cb3464e86 hel: pb multi-sink copy, enc command-consumer, proper help
pb: built-in fan-out to every clipboard on PATH (pbcopy/wl-copy/xclip
-selection clipboard/xsel -ib/tmux in a session); no HEL_PB script needed.
Override via 'set hel_pb' or $HEL_PB still honored; no sink -> data on stdout.

enc: takes a sub-command like pb. 'enc ls|ld <regex>' encodes the last match
(newest for ld); literal name/id wins first. ls/ld emit bare names when
captured (LKEval.capture), so 'pb ld <re>' copies names. Producers limited to
ls/ld (enc never runs a mutating command). 'set hel_enc_strict 1' errors on
>1 match.

help: 'help [topic]' — grouped overview + per-topic detail (every command, the
entry descriptor, all modes with examples, parent/master chaining, config keys,
files/env, Notion store/load subcommands). Old inline help string removed.

Tests added for all three; native + wasm32 builds green.
2026-06-14 17:58:05 +02:00
Oleksandr Kozachuk 11c0198e34 gitignore local scratch: login exports, test artifacts, dead my-app scaffold, editor config 2026-06-11 15:42:41 +02:00
ok a31ae9669f helwasm: footer version badge + changelog overlay 2026-06-10 14:46:14 +02:00
Oleksandr Kozachuk db05834fd1 helwasm: cache-bust favicon links (?v=2) so the new key icon refreshes
The new key favicon is already live and correct (md5 verified); browsers cache
favicons aggressively and keep showing the old enso. A version query forces a re-fetch.
2026-06-10 11:30:18 +02:00
Oleksandr Kozachuk 1fd61e5fc4 helwasm: favicon = official enso + key (render from icon-source.svg) 2026-06-10 11:22:40 +02:00
Oleksandr Kozachuk a88f9f6c88 helwasm: show the wordmark in standalone app mode (back link gone = room) 2026-06-10 00:31:51 +02:00
Oleksandr Kozachuk 90dd2532b5 helwasm: Enter in the master field copies the result (like Copy) 2026-06-10 00:26:59 +02:00
Oleksandr Kozachuk 507335168c helwasm: hide back-to-Kaizenkodo link when launched as an installed app
In standalone/home-screen launch, leaving the app context feels wrong, so hide the
back link. Detected via navigator.standalone (older iOS) + display-mode:standalone
media query / html.standalone class.
2026-06-10 00:24:45 +02:00
Oleksandr Kozachuk b25408d283 helwasm: Enter in the name field jumps to the master field 2026-06-10 00:21:25 +02:00
Oleksandr Kozachuk 11476aecbc helwasm: shrink result font to fit one line on narrow screens
fitResult() measures the result's natural single-line width and scales the font down
(to an 11px floor) so a long password fits on one line on iPhone instead of wrapping;
wraps only if even the floor can't fit. Re-fits on resize/rotate. Masking doesn't
change width, so reveal is unaffected.
2026-06-10 00:19:33 +02:00
Oleksandr Kozachuk fa0fd841b6 helwasm: shorten quick-gen hint (iphone); round out the About panel
Hint cut ~35% to two sentences. About 'Console' now notes form<->console share the
same catalog/session and that pb copies to the clipboard; About note mentions
home-screen install.
2026-06-10 00:13:57 +02:00
Oleksandr Kozachuk ebb09c7cb9 help: list every command (add keep, pb, and the other/help/quit line)
keep <n> (keep a listed ls/gen entry by its left-column index), pb <command> (copy a
command's output to the clipboard), and a final line for help / # comments / quit were
missing. cargo test green; rebuilt wasm.
2026-06-10 00:07:59 +02:00
Oleksandr Kozachuk eef2104f25 helwasm: let hint + About text fill the block (drop ch max-width caps) 2026-06-09 23:59:49 +02:00
Oleksandr Kozachuk 15245d027c helwasm: fix hint — Store files the entry (name + rules), not just the name
Store saves the full entry spec (name, length, mode, seq, date, parent), never the
password; the old wording said 'just the entry name', which contradicts the recall-
full-spec behaviour. Fixed the quick-gen hint and the About blurb.
2026-06-09 23:57:10 +02:00
Oleksandr Kozachuk 4c6884c947 helwasm: footer respects the body gutter (use padding-block)
The .foot padding shorthand zeroed the inline padding, overriding .wrap's gutter so
the footer text ran to the window edge. Use padding-block so it lines up with the
body cards.
2026-06-09 23:54:13 +02:00
Oleksandr Kozachuk 7c651d0bb5 pb: copy to the clipboard in the browser
Instead of erroring, the wasm `pb` branch now writes the inner command's data to the
host page's clipboard via navigator.clipboard (new hel_clipboard_write host hook).
Runs inside the Enter-keypress handler, so the clipboard user-gesture requirement is
met. Native pbcopy/xclip path unchanged.
2026-06-09 23:51:51 +02:00
Oleksandr Kozachuk 30fbc1caae helwasm: merge Store + Mark correct into one progressive button
The two buttons encoded different facts (catalog membership vs verified hash) but
looked alike. Combine into a single button that walks the states, each a real engine
command: Store (add) -> Correct (correct) -> Correct ✓ (uncorrect), and Stored ✓
(disabled) when filed but no master is typed. Shorter label: "Correct", not
"Mark correct". Help texts updated.
2026-06-09 23:45:31 +02:00
Oleksandr Kozachuk 2f55846f70 helwasm: Stored button matches Correct (filled ocean); refocus console after pass modal
- is-stored now uses the same filled-ocean look as is-correct, so both confirmed
  states read identically
- closing the pass overlay (Set or Cancel) returns focus to the command line
2026-06-09 23:31:46 +02:00
Oleksandr Kozachuk 05613813de parser: a mode must end at a word boundary
A single-letter mode was stolen from the middle of a longer word, so `gen testG r
now` mis-parsed as name=r, mode=n (the 'n' of 'now'), leaving 'ow' dangling and
failing with a trailing-input error. Require a non-alphanumeric after a mode so 'now'
parses as the date it is. Adds a regression test.
2026-06-09 23:24:57 +02:00
Oleksandr Kozachuk 0dd173f113 helwasm: store-on-correct, store button reflects existence, use stored spec
- new read-only export hel_entry(name): the entry's canonical stored form, or ""
- Mark correct now also stores the name (keeps the entry); unmark keeps the stored
  name and just drops the hash (wraps add/rm for an ephemeral entry so the right
  hash is removed)
- Store button shows "Stored ✓" (disabled, ocean outline) when the name is already
  in the catalog, "Store" otherwise
- when a typed name already exists, the field adopts the full stored spec on blur
  (real mode/seq/date/parent) instead of a default-extended parse
2026-06-09 23:12:48 +02:00
Oleksandr Kozachuk c60e6c8ce2 helwasm: colour is per name+master, not per root master
Mark correct now stores/checks the leaf's own SHA1(name||password) (correct <name>),
read from the leaf warning in the enc output. Changing the name (or mistyping the
master) drops back to black until that exact name+master is marked. Mark/unmark wraps
add/rm for an ephemeral (not Stored) entry.
2026-06-09 22:53:44 +02:00
Oleksandr Kozachuk 10a21d1f9d helwasm: hierarchical quick-gen, pure sugar over the engine
Quick form now drives the one shared engine through commands only, so it and the
console give identical results (form == console).

- password hook answers only the "/" prompt, so read_master climbs and computes
  every ^parent from a single root master (Req 4)
- new master in the field is authoritative via `unpass`; empty field reuses the
  stored session master, so later names generate without re-typing it (Req 1/2)
- `unpass` with no name now clears ALL cached masters (real command, not a private
  hook) so the form stays reproducible in the console
- result colour = root master marked correct (correct / / uncorrect /), derived
  live from the engine; black otherwise (Req 3)
- Mark-correct toggle button; click the master to reveal it; honest memory-only hint
- master stays in memory only, never written to storage; only the one-way correct
  hash is saved on the device

parser: bare-name forms now keep a trailing comment, and a ^-led token is never a
name, so `name ^parent` / `name MODE ^parent` link the parent instead of qpname
reading it as prefix+name (was silently mis-stored).
2026-06-09 22:45:44 +02:00
Oleksandr Kozachuk e25746d5f0 web: add an always-visible '← Kaizenkodo' back link to the main site
The brand only reloaded the tool and the byline was hidden on mobile, so there
was no easy way back. Add a pill back link (href='../') as the first header item,
shown on every width; drop the wordmark below 540px so it still fits on phones.
2026-06-09 21:38:14 +02:00
Oleksandr Kozachuk fc0e9bfafd web: clarify + shorten the Store/privacy hint (browser-only, never a server) 2026-06-08 23:11:58 +02:00
Oleksandr Kozachuk 1370e640fb web: drop em-dashes from style.css comments 2026-06-08 23:03:30 +02:00
Oleksandr Kozachuk 1005e5a8e2 web: forward-port the no-horizontal-scroll rules into source style.css (de-em-dashed comments) 2026-06-08 23:02:44 +02:00
Oleksandr Kozachuk 29c26ddda1 web: drop em-dashes from the help/UI copy, reword to not need them 2026-06-08 23:00:41 +02:00
Oleksandr Kozachuk 77352093c9 web: official ensō+key icon, Kaizenkodo link, installable home-screen app
- App icon = the official Kaizenkodo ensō (logo/current/exports/tight) with a key
  inside: cream bg for the home-screen/app icon, transparent for the header +
  favicons. Source: assets/enso-base.png + icon-source.svg / enso-mark.svg.
- Attribution: a 'by Kaizenkodo' header link and a footer line, both to kaizenkodo.no.
- PWA install: manifest (standalone, start_url/scope '../', maskable icons) + iOS
  metas (apple-mobile-web-app-capable/title/status-bar) so it adds to the iPhone
  home screen and runs as a standalone app.
2026-06-08 22:39:30 +02:00
Oleksandr Kozachuk b163cc440f web: stack the result row cleanly on phones
On narrow screens the result row (tag + password + len + buttons) wrapped
awkwardly. Reorder under @media: password on its own full-width line, then a
tidy meta line (result + len ........ Copy / Store).
2026-06-08 22:18:07 +02:00
Oleksandr Kozachuk be2078779d web: quick-gen result scales + wraps on small screens
The result password was a fixed 19px with white-space:pre, so it overflowed the
card on phones. Scope .result .secret to a dynamic font (clamp 13-19px, ~15px at
390px) that wraps (pre-wrap + overflow-wrap:anywhere + min-width:0). Desktop is
unchanged (fits one line at 19px); the console gen-table keeps its pre alignment.
2026-06-08 22:12:53 +02:00
Oleksandr Kozachuk 509a66a609 web: prevent iOS focus-zoom — inputs/textarea >= 16px
iOS Safari auto-zooms (and stays zoomed) when a focused input is under 16px.
Bumped the form inputs (15px) and modal textareas (13px) to 16px; the console
input inherits the base. No viewport user-scalable hack (keeps pinch-zoom).
2026-06-08 22:04:55 +02:00
Oleksandr Kozachuk db0eb9ebc4 web: stop browser autofill on the name + master fields
The master and pass-overlay inputs are now type=text with a CSS visual mask
(-webkit-text-security) instead of type=password, so the browser never treats
them as password fields — no autofill, no save prompt, no 'suggest strong
password'. Added autocomplete=off + autocapitalize/autocorrect off and
data-1p-ignore / data-lpignore / data-bwignore on the name + master + pass.
2026-06-08 21:59:16 +02:00
Oleksandr Kozachuk d44a54002b parser: default mode for a bare name is now R (six S/KEY words), not C
Matches the prefix-name default, the README, and the app's own help text (which
already says the default is six memorable words). Explicit modes are unaffected,
so existing catalogs round-trip unchanged.
2026-06-08 21:51:03 +02:00
Oleksandr Kozachuk 14d404744c web: fix mobile horizontal overflow
main is a grid; its items default to min-width:auto, so the console card (pre /
no-wrap lines) refused to shrink and pushed the whole page wider than the phone
screen, clipping the About panel text. Set grid-template-columns: minmax(0,1fr)
so cards shrink to the viewport and the console scrolls internally instead.
2026-06-08 21:41:35 +02:00
Oleksandr Kozachuk 349bbe1557 web: link RFC 2289 in the About panel 2026-06-08 21:24:59 +02:00
Oleksandr Kozachuk f8e8a9981a help: note the S/KEY (RFC 2289) heritage + xkcd 936 memorable-words idea
The default R mode is the six-word S/KEY form. Mention it in the help command
(CLI + web console) and the web About panel, with a link to xkcd 936.
2026-06-08 21:15:15 +02:00
Oleksandr Kozachuk ba884037b0 web: state the no-server/in-browser privacy clearly + self-host fonts
- Make the privacy promise true: bundle the fonts (Poppins/Inter/JetBrains Mono
  latin subsets + the text-security mask font) under helwasm/assets/fonts/ and
  drop the Google Fonts + jsDelivr links. The page now makes zero external
  requests once loaded — it talks to no third party.
- Say it plainly throughout the UI: a "Private by design" callout in the About
  panel, the meta description, the quick-gen and console hints, and the footer —
  nothing you type leaves the browser, no server, no account, no tracking.
2026-06-08 20:42:33 +02:00
Oleksandr Kozachuk 3031105000 feat(web): polish the WASM site - UX, help, parsing, assets
- About panel (collapsible, closed by default with a pulsing "Help" badge)
  explaining the tool; the `help` command now prints a full command reference
  instead of "HELP" (benefits the CLI too).
- Live generation as you type; Store button; masked output that stays mono-width
  (gen masks only the password column, never the header); command history with
  Up/Down (persisted); console auto-scroll; Export fixed (uses `dump`); Import.
- Name field normalizes to its canonical parsed form on blur; quick-gen and Store
  resolve the real entry name, so a leading prefix like `*P0 test1` is handled
  correctly (new hel_parse / hel_parse_name wasm helpers).
- Parser: prefix-only forms now parse with defaults - `*P0 test1` ->
  `*P0 test1 R 99 <today>`, `*P0 test1 R`, `*P0 test1 20R` - via new qpname/npname
  rules mirroring qname/nname; the fully-qualified form is unchanged.
- Favicons + web app manifest + enso asset; pkg rebuilt.
2026-06-08 20:11:24 +02:00
Oleksandr Kozachuk 03c5dadccb feat: WASM web app (browser LesS/KEY) backed by the hel core
Run the real hel core in the browser via WebAssembly, replacing the plain-JS
ooke.github.io/sk reimplementation so the web and the CLI share one generator.

- hel core: new storage module (native fs / wasm localStorage) backs
  init/source/dump/correct; the pipe and pb branches are gated out of wasm;
  the wasm editor is completed (EditorRef) and its password prompt no longer
  blocks the single JS thread (synchronous hel_get_password instead of a
  thread::sleep poll).
- helwasm: hel_load_script for bulk import, a panic hook in hel_init, dropped
  the ok_add / web-sys leftovers; build.sh runs wasm-bindgen into a committed
  pkg/ so GitHub Pages can serve the static dir with no CI.
- UI: rewritten index.html + style.css in the kaizenkodo.org style — responsive,
  masked-but-copyable passwords with click-to-reveal, a quick-generate card plus
  a full command console, localStorage persistence, and paste import/export.
2026-06-08 18:43:19 +02:00
37 changed files with 2514 additions and 312 deletions
+21
View File
@@ -12,3 +12,24 @@ Cargo.lock
# MSVC Windows builds of rustc generate these, which store debugging information # MSVC Windows builds of rustc generate these, which store debugging information
*.pdb *.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
+470 -91
View File
@@ -1,28 +1,289 @@
use regex::Regex; use regex::Regex;
use sha1::{Digest, Sha1}; use sha1::{Digest, Sha1};
use std::cmp::min; use std::cmp::min;
use std::collections::{HashMap, HashSet}; use std::collections::HashSet;
use std::fs;
use std::io::{BufRead, BufReader};
use std::io::{BufWriter, Write};
use crate::parser::command_parser; use crate::parser::command_parser;
use crate::password::fix_password_recursion; use crate::password::fix_password_recursion;
use crate::password::{Name, Password, PasswordRef}; use crate::password::{Name, Password, PasswordRef};
use crate::repl::LKEval; 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::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> { 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> { pub fn get_password(&self, name: &String) -> Option<PasswordRef> {
match self.state.lock().borrow().ls.get(name) { if let Some(pwd) = self.state.lock().borrow().ls.get(name) {
Some(pwd) => Some(pwd.clone()), return 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().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> { pub fn read_master(&self, out: &LKOut, pwd: PasswordRef, read: bool) -> Option<String> {
@@ -207,25 +468,115 @@ impl<'a> LKEval<'a> {
Some((name, pass)) 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) { pub fn cmd_pb(&self, out: &LKOut, command: &String) {
match command_parser::cmd(command) { match command_parser::cmd(command) {
Ok(cmd) => { 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(); let data = print.out.data();
print.out.copy_err(&out); print.out.copy_err(&out);
if data.len() > 0 { if data.len() > 0 {
let (copy_command, copy_cmd_args) = get_copy_command_from_env(); // Clipboard copy shells out to pbcopy/xclip/tmux: native only.
match call_cmd_with_input(&copy_command, &copy_cmd_args, &data) { // In the browser the page provides a Copy button instead.
Ok(s) if s.len() > 0 => { #[cfg(target_arch = "wasm32")]
out.o(format!( {
"Copied output with the command {}, and got following output:", hel_clipboard_write(&data);
copy_command out.o(format!("Copied {} characters to the clipboard", data.chars().count()));
)); }
out.o(s.trim().to_string()); #[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(&copy_command, &copy_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())), 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 { pub fn cmd_source(&self, out: &LKOut, source: &String) -> bool {
out.o(format!("source {}", source)); out.o(format!("source {}", source));
let script = if source.trim().ends_with("|") { let script: String;
let (cmd, args) = match get_cmd_args_from_command(source.trim().trim_end_matches('|')) { if source.trim().ends_with("|") {
Ok(c) => c, // 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) => { 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; 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) { match command_parser::script(&script) {
Ok(cmd_list) => { Ok(cmd_list) => {
for cmd in 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()), None => config_get("hel_dump").unwrap_or_else(|| DUMP_FILE.to_str().unwrap().to_string()),
}; };
let script = shellexpand::full(&script).unwrap().into_owned(); let script = shellexpand::full(&script).unwrap().into_owned();
fn save_dump(data: &HashMap<Name, PasswordRef>, script: &String) -> std::io::Result<()> {
let file = fs::File::create(script)?;
let mut writer = BufWriter::new(file);
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("|") { if script.trim().starts_with("|") {
let (cmd, args) = match get_cmd_args_from_command(script.trim().trim_start_matches('|')) { // Piping the dump to a command needs a subprocess: native only.
Ok(c) => c, #[cfg(target_arch = "wasm32")]
Err(e) => { {
out.e(format!("error: failed to parse command {:?}: {}", script, e.to_string())); out.e("error: pipe dump is not available in the browser".to_string());
return; }
#[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() == "-" { } else if script.trim() == "-" {
let mut vals = (&self.state.lock().borrow().db).values().map(|v| v.clone()).collect::<Vec<PasswordRef>>(); 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())) out.o(format!("add {}", pwd.lock().borrow().to_string()))
} }
} else { } else {
// File path on native; localStorage key in the browser.
let data = self.serialize_db(); let data = self.serialize_db();
self.show_dump_diff(out, &data); self.show_dump_diff(out, &data);
// Bind first so the immutable borrow drops before the borrow_mut below. // Trailing newline to match the historical file format (writeln per line).
let res = save_dump(&self.state.lock().borrow().db, &script); let body = if data.is_empty() { String::new() } else { format!("{}\n", data) };
match res { match crate::storage::write(&script, &body) {
Ok(()) => { Ok(()) => {
self.state.lock().borrow_mut().last_dump = Some(data); 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())), 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(); let key = Radix::new(counter, 36).unwrap().to_string();
counter += 1; counter += 1;
self.state.lock().borrow_mut().ls.insert(key.clone(), pwd.clone()); 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, None => return,
}; };
fn load_lines() -> std::io::Result<HashSet<String>> { fn load_lines() -> std::io::Result<HashSet<String>> {
let file = fs::File::open(CORRECT_FILE.to_str().unwrap())?; let content = crate::storage::read(CORRECT_FILE.to_str().unwrap())?;
let reader = BufReader::new(file);
let mut lines = HashSet::new(); let mut lines = HashSet::new();
for line in reader.lines() { for line in content.lines() {
lines.insert(line?.trim().to_owned()); let line = line.trim();
if !line.is_empty() {
lines.insert(line.to_owned());
}
} }
Ok(lines) Ok(lines)
} }
@@ -469,12 +839,12 @@ impl<'a> LKEval<'a> {
data.remove(&encpwd); data.remove(&encpwd);
} }
fn save_lines(data: &HashSet<String>) -> std::io::Result<()> { fn save_lines(data: &HashSet<String>) -> std::io::Result<()> {
let file = fs::File::create(CORRECT_FILE.to_str().unwrap())?; let mut content = String::new();
let mut writer = BufWriter::new(file);
for entry in data { 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) { match save_lines(&data) {
Ok(()) => out.o(format!( Ok(()) => out.o(format!(
@@ -552,13 +922,22 @@ impl<'a> LKEval<'a> {
encpwds.sort_by(|a, b| b.1.len().cmp(&a.1.len())); encpwds.sort_by(|a, b| b.1.len().cmp(&a.1.len()));
self.state.lock().borrow_mut().ls.clear(); self.state.lock().borrow_mut().ls.clear();
let mut counter = 1; 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() { for num in (encpwds.len() - min(genpwds.len(), num))..encpwds.len() {
let (pwd, pass) = (encpwds[num].0.clone(), encpwds[num].1.to_string()); let (pwd, pass) = (encpwds[num].0.clone(), encpwds[num].1.to_string());
let key = Radix::new(counter, 36).unwrap().to_string(); let key = Radix::new(counter, 36).unwrap().to_string();
counter += 1; counter += 1;
self.state.lock().borrow_mut().ls.insert(key.clone(), pwd.clone()); 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()));
}
} }
} }
} }
+1
View File
@@ -11,5 +11,6 @@ pub mod parser;
pub mod password; pub mod password;
pub mod repl; pub mod repl;
pub mod skey; pub mod skey;
pub mod storage;
pub mod structs; pub mod structs;
pub mod utils; pub mod utils;
+89 -9
View File
@@ -37,13 +37,22 @@ peg::parser! {
{ Password::new(None, pn, pl, pm, ps, pd, pc) } { 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()? 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) } { 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()? 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) } { Password::new(None, pn, pl, pm, 99, pd, pc) }
rule nname() -> Password = &(word() _ num()? mode()) pn:word() _ pl:num()? pm:mode() rule nname() -> Password = &(word() _ num()? mode()) pn:word() _ pl:num()? pm:mode() pc:comment()?
{ Password::new(None, pn, pl, pm, 99, Date::now(), None) } { Password::new(None, pn, pl, pm, 99, Date::now(), pc) }
rule qname() -> Password = &(word()) pn:word() // prefix + name (+ optional comment) -> defaults mode R, seq 99, date now.
{ Password::new(None, pn, None, Mode::NoSpaceCamel, 99, Date::now(), None) } // A `^parent` token is never a name, so a `^`-leading second word fails here and
pub rule name() -> Password = name:(jname() / pname() / mname() / sname() / nname() / qname())? {? // 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") } match name { Some(n) => Ok(n), None => Err("failed to parse password description") }
} }
@@ -76,10 +85,13 @@ peg::parser! {
_ => Err("unknown mode"), _ => 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 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 quit_cmd() -> Command<'input> = "quit" { Command::Quit }
rule pb_cmd() -> Command<'input> = "pb" _ e:$(([' '..='~'])+) { Command::PasteBuffer(e.to_string()) } 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())) } 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 pass_cmd() -> Command<'input> = p:(pass_long_cmd() / pass_short_cmd()) { p }
rule correct_cmd() -> Command<'input> = "correct" _ name:word() { Command::Correct(name) } rule correct_cmd() -> Command<'input> = "correct" _ name:word() { Command::Correct(name) }
rule uncorrect_cmd() -> Command<'input> = "uncorrect" _ name:word() { Command::Uncorrect(name) } rule uncorrect_cmd() -> Command<'input> = "uncorrect" _ name:word() { Command::Uncorrect(name) }
rule unpass_cmd() -> Command<'input> = "unpass" _ name:word() { Command::UnPass(name) } rule unpass_cmd() -> Command<'input> = "unpass" name:(_ w:word() { w })? { Command::UnPass(name) }
rule enc_cmd() -> Command<'input> = "enc" _ name:word() { Command::Enc(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 rm_cmd() -> Command<'input> = "rm" _ name:word() { Command::Rm(name) }
rule comment_cmd() -> Command<'input> = "comment" _ name:word() c:comment()? { Command::Comment(name, c) } 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] #[test]
fn parse_password_test() { fn parse_password_test() {
assert_eq!( assert_eq!(
+140 -6
View File
@@ -19,6 +19,12 @@ pub struct LKEval<'a> {
pub cmd: Command<'a>, pub cmd: Command<'a>,
pub state: LKRef, pub state: LKRef,
pub read_password: fn(String) -> std::io::Result<String>, 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)] #[derive(Debug)]
@@ -80,9 +86,16 @@ impl<'a> LKEval<'a> {
cmd, cmd,
state, state,
read_password, 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 { 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")) }) 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)), None => out.e(format!("error: password {} not found", name)),
}, },
Command::Enc(name) => { Command::Enc(arg) => {
self.cmd_enc(&out, name); self.cmd_enc_arg(&out, arg);
} }
Command::Gen(num, name) => self.cmd_gen(&out, &num, &name), Command::Gen(num, name) => self.cmd_gen(&out, &num, &name),
Command::PasteBuffer(command) => self.cmd_pb(&out, command), 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::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, None) => self.cmd_pass(&out, &name, &None),
Command::Pass(name, pass) => { to_history = false; self.cmd_pass(&out, &name, &pass); }, 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)), Some(_) => out.o(format!("Removed saved password for {}", name)),
None => out.e(format!("error: saved password for {} not found", 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::Correct(name) => self.cmd_correct(&out, name, true, None),
Command::Uncorrect(name) => self.cmd_correct(&out, name, false, None), Command::Uncorrect(name) => self.cmd_correct(&out, name, false, None),
Command::Noop => { to_history = false; }, Command::Noop => { to_history = false; },
Command::Help => { Command::Help(topic) => self.cmd_help(&out, topic),
out.o("HELP".to_string());
}
Command::Mv(name, folder) => self.cmd_mv(&out, &name, &folder), Command::Mv(name, folder) => self.cmd_mv(&out, &name, &folder),
Command::Error(error) => { Command::Error(error) => {
to_history = false; to_history = false;
@@ -433,4 +448,123 @@ mod tests {
LKEval::news(Command::Pass("t1".to_string(), Some("other pw".to_string())), lk.clone()).eval(); 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"); 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
}
} }
+48
View File
@@ -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(())
}
+17 -6
View File
@@ -67,6 +67,15 @@ pub fn config_get(key: &str) -> Option<String> {
std::env::var(key.to_uppercase()).ok() 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 /// The runtime config as `(UPPERCASE_KEY, value)` pairs, for injection into the
/// environment of child processes (so `set hel_notion_token …` reaches a spawned /// environment of child processes (so `set hel_notion_token …` reaches a spawned
/// `hel store`/`hel load`). /// `hel store`/`hel load`).
@@ -97,7 +106,7 @@ pub enum Command<'a> {
Enc(Name), Enc(Name),
Gen(u32, PasswordRef), Gen(u32, PasswordRef),
Pass(Name, Option<String>), Pass(Name, Option<String>),
UnPass(Name), UnPass(Option<Name>),
Correct(Name), Correct(Name),
Uncorrect(Name), Uncorrect(Name),
PasteBuffer(String), PasteBuffer(String),
@@ -107,7 +116,7 @@ pub enum Command<'a> {
Comment(Name, Comment), Comment(Name, Comment),
Error(LKErr<'a>), Error(LKErr<'a>),
Noop, Noop,
Help, Help(Option<Name>),
Quit, Quit,
} }
@@ -133,7 +142,7 @@ impl<'a> PartialEq for Command<'a> {
(Command::Comment(a, b), Command::Comment(x, y)) => a == x && b == y, (Command::Comment(a, b), Command::Comment(x, y)) => a == x && b == y,
(Command::Error(s), Command::Error(o)) => s == o, (Command::Error(s), Command::Error(o)) => s == o,
(Command::Noop, Command::Noop) => true, (Command::Noop, Command::Noop) => true,
(Command::Help, Command::Help) => true, (Command::Help(s), Command::Help(o)) => s == o,
(Command::Quit, Command::Quit) => true, (Command::Quit, Command::Quit) => true,
_ => false, _ => 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::Gen(a, b) => write!(f, "gen{} {}", a, b.lock().borrow().to_string().trim()),
Command::Pass(a, None) => write!(f, "pass {}", a), Command::Pass(a, None) => write!(f, "pass {}", a),
Command::Pass(a, Some(b)) => write!(f, "pass {} {}", a, b), 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::Correct(s) => write!(f, "correct {}", s),
Command::Uncorrect(s) => write!(f, "uncorrect {}", s), Command::Uncorrect(s) => write!(f, "uncorrect {}", s),
Command::PasteBuffer(s) => write!(f, "pb {}", 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::Comment(a, Some(b)) => write!(f, "comment {} {}", a, b),
Command::Error(s) => write!(f, "error {}", s), Command::Error(s) => write!(f, "error {}", s),
Command::Noop => write!(f, "noop"), 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"), 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 lk = Arc::new(ReentrantMutex::new(RefCell::new(LK::new())));
let editor = Editor::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(script) => match command_parser::script(&script) {
Ok(cmd_list) => { Ok(cmd_list) => {
for cmd in cmd_list { for cmd in cmd_list {
+82 -24
View File
@@ -1,6 +1,4 @@
use shlex::split; use shlex::split;
use std::env;
use std::ffi::OsString;
use std::io; use std::io;
use std::io::Write; use std::io::Write;
use std::process::{Command, Stdio}; use std::process::{Command, Stdio};
@@ -150,25 +148,32 @@ pub mod editor {
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
pub mod editor { pub mod editor {
use crate::structs::LKErr; use crate::structs::LKErr;
use parking_lot::Mutex;
use std::sync::Arc;
use wasm_bindgen::prelude::*; 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] #[wasm_bindgen]
extern "C" { extern "C" {
#[wasm_bindgen(js_name = hel_read_password)] // Synchronous: the host page returns the current master-password value.
fn extern_read_password(prompt: &str); // (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_current_password)] #[wasm_bindgen(js_name = hel_get_password)]
fn extern_current_password() -> Option<String>; fn extern_get_password(prompt: &str) -> String;
} }
#[derive(Debug)] #[derive(Debug)]
pub struct Editor { pub struct Editor {
#[allow(dead_code)]
history: Vec<String>, history: Vec<String>,
} }
impl Editor { impl Editor {
pub fn new() -> Self { pub fn new() -> EditorRef {
Self { history: vec![] } Arc::new(Mutex::new(Self { history: vec![] }))
} }
pub fn clear_history(&mut self) { pub fn clear_history(&mut self) {
@@ -193,13 +198,7 @@ pub mod editor {
} }
pub fn password(prompt: String) -> std::io::Result<String> { pub fn password(prompt: String) -> std::io::Result<String> {
extern_read_password(&prompt); Ok(extern_get_password(&prompt))
loop {
match extern_current_password() {
Some(p) => return Ok(p),
None => std::thread::sleep(std::time::Duration::from_millis(100)),
}
}
} }
} }
@@ -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())) Ok((shellexpand::full(&args[0]).unwrap().into_owned(), args[1..].to_vec()))
} }
pub fn get_copy_command_from_env() -> (String, Vec<String>) { /// Outcome of a built-in fan-out copy: which sinks accepted the data and which
let cmd_os_str = env::var_os("HEL_PB").unwrap_or_else(|| match env::consts::OS { /// failed (best-effort, like the reference shell script's `2>/dev/null`).
_ if env::var("TMUX").is_ok() => OsString::from("tmux load-buffer -"), #[cfg(not(target_arch = "wasm32"))]
"macos" => OsString::from("pbcopy"), #[derive(Debug, Default, PartialEq)]
"linux" => OsString::from("xclip"), pub struct CopyReport {
_ => OsString::from("cat"), pub ok: Vec<String>,
}); pub err: Vec<(String, String)>,
get_cmd_args_from_command(&cmd_os_str.to_string_lossy()).unwrap_or_else(|_| ("cat".to_string(), vec![])) }
/// 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)] #[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] #[test]
fn check_correct_stdin() { fn check_correct_stdin() {
let cmd = "cat"; let cmd = "cat";
+1 -4
View File
@@ -16,7 +16,4 @@ hel = { version = "0.1.0", path = "../hel" }
lazy_static = "1.4.0" lazy_static = "1.4.0"
wasm-bindgen = "0.2.83" wasm-bindgen = "0.2.83"
parking_lot = "0.12.1" parking_lot = "0.12.1"
console_error_panic_hook = "0.1"
[dependencies.web-sys]
version = "0.3.4"
features = [ 'Document' ]
Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 251 KiB

+4
View File
@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 630 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

+5
View File
@@ -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

+25
View File
@@ -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"
}
]
}
+12
View File
@@ -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)"
+699 -161
View File
@@ -1,172 +1,710 @@
<!DOCTYPE html> <!DOCTYPE html>
<html> <html lang="en">
<head> <head>
<script src="https://unpkg.com/react@18/umd/react.development.js" crossorigin></script> <meta charset="utf-8" />
<script src="https://unpkg.com/react-dom@18/umd/react-dom.development.js" crossorigin></script> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script> <meta name="theme-color" content="#0a2540" />
<style> <title>LesS/KEY password generator</title>
html { <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." />
height: 100%; <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" />
body { <link rel="apple-touch-icon" sizes="180x180" href="./assets/apple-touch-icon.png?v=2" />
min-height: 100%; <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" />
html, <meta name="mobile-web-app-capable" content="yes" />
body { <meta name="apple-mobile-web-app-title" content="LesS/KEY" />
background-color: #000; <meta name="apple-mobile-web-app-status-bar-style" content="default" />
width: 100%; <!-- Fonts are self-hosted in style.css (./assets/fonts/); no external requests. -->
margin: 0; <link rel="stylesheet" href="./style.css" />
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>
</head> </head>
<body> <body>
<div id="root"></div> <header class="site">
<script type="module"> <div class="wrap nav">
import init, { hel_init, hel_command } from "./pkg/helwasm.js"; <a class="backlink" href="../" title="Back to the Kaizenkodo site"><span class="arr" aria-hidden="true"></span> Kaizenkodo</a>
init().then(() => { <a class="brand" href="./">
window.hel = { <img class="enso" src="./assets/enso.png" width="36" height="36" alt="" aria-hidden="true" />
hel_init: hel_init, <span class="wordmark">LesS/KEY</span>
hel_command: hel_command, <span class="tagline">password generator</span>
} </a>
}); <span class="nav-spacer"></span>
</script> <button class="btn ghost" id="importBtn">Import</button>
<script type="text/babel"> <button class="btn ghost" id="exportBtn">Export</button>
function Terminal() { </div>
const prompt = "> "; </header>
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);
React.useEffect(() => { <main class="wrap">
outputRef.current.scrollIntoView({ behaviour: "smooth" }); <!-- About / how it works (collapsible) -->
}, [output]); <details class="about" id="about">
<summary>
function handleInput(event) { <span class="about-badge">Help</span>
setInput(event.target.value); <span>What is LesS/KEY &amp; how it works</span>
} <span class="about-hint">click to open</span>
<span class="about-toggle" aria-hidden="true"></span>
function handleSubmit(event) { </summary>
event.preventDefault(); <div class="about-body">
const result = executeCommand(input); <p>
setOutput([...output, { prompt: prompt, command: input }, { prompt: "", command: result }]); LesS/KEY is a password manager that <strong>stores no passwords</strong>. It
setInput(""); <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,
function handlePasswordInput(event) { sync, or back up, and you only ever remember one phrase. By default each password
setPasswordInput(event.target.value); 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>).
function handlePasswordSubmit(event) { </p>
window.hel_current_password_value = passwordInput; <p class="about-privacy">
setPasswordPrompt(undefined); <strong>Nothing leaves your device.</strong> Everything happens locally. Your master
setPasswordInput(""); 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,
function helReadPassword(prompt) { it makes no network requests at all.
window.hel_current_password_value = null; </p>
setPasswordPrompt(prompt); <div class="about-grid">
} <div>
window.hel_read_password = helReadPassword; <h4>Quick password</h4>
<p>
function helCurrentPassword(prompt) { Type an account name and your master phrase. The password appears instantly,
return window.hel_current_password_value; 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
window.hel_current_password = helCurrentPassword; 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.
function executeCommand(command) { </p>
return window.hel.hel_command(command); </div>
} <div>
<h4>Console</h4>
return ( <p>
<div className="terminal"> The full command line; type <code>help</code> for every command. It shares the
{output.map(({ prompt, command }, index) => (<p key={index}>{prompt && (<span className="prompt">{prompt}</span>)}{command}</p>))} same catalog and session as the form above, so you can switch between them freely;
<form onSubmit={handleSubmit}> <code>pb</code> copies a command's output to the clipboard. Your list lives only in
<label> this browser (localStorage); use <strong>Export</strong> or <strong>Import</strong>
<span className="prompt" ref={outputRef}>{prompt}</span> to move it yourself (for example, to and from a Notion page).
<input </p>
type="text" </div>
value={input} <div>
onChange={handleInput} <h4>Naming rules</h4>
className="input" <p>
autoFocus 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;
</label> <code>D</code> digits (<code>U…</code> = upper). For example, <code>github 20R</code>.
</form> End a name with <code>^folder</code> to derive it from a parent; you still type only
{passwordPrompt !== undefined && (<form onSubmit={handlePasswordSubmit}><label><span className="prompt">{passwordPrompt}</span><input type="password" value={passwordInput} onChange={handlePasswordInput} className="input" autoFocus /></label></form>)} your one master and the whole chain is computed.
</p>
</div>
</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">&gt;</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'); // ---- host imports (wasm calls these by bare name → must be globals) ----
const root = ReactDOM.createRoot(container); // The master field is the single ROOT master. hel's read_master prompts "/" for
root.render(<Terminal />); // 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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
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">&gt; ' + 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) || "&nbsp;"); 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) || "&nbsp;");
}
}
}
// ---- 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">&gt; 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> </script>
</body> </body>
</html> </html>
+82
View File
@@ -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>;
+391
View File
@@ -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 };
Binary file not shown.
+14
View File
@@ -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;
+62 -5
View File
@@ -1,22 +1,79 @@
use hel::lk::{LK, LKRef}; use hel::lk::{LK, LKRef};
use hel::repl::LKRead; use hel::parser::command_parser;
use hel::utils::editor::Editor; use hel::repl::{LKEval, LKRead};
use std::sync::Arc; use hel::structs::LKOut;
use hel::utils::editor::{password, Editor};
use parking_lot::ReentrantMutex; use parking_lot::ReentrantMutex;
use std::cell::RefCell; use std::cell::RefCell;
use std::sync::Arc;
use wasm_bindgen::prelude::*; use wasm_bindgen::prelude::*;
lazy_static! { lazy_static! {
static ref STATE: LKRef = Arc::new(ReentrantMutex::new(RefCell::new(LK::new()))); 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] #[wasm_bindgen]
pub fn hel_command(cmd: String) -> String { pub fn hel_command(cmd: String) -> String {
let editor = Editor::new(); let editor = Editor::new();
let mut lkread = LKRead::new(editor, "> ".to_string(), STATE.clone()); 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 lkeval = lkread.read();
let lkprint = lkeval.eval(); let lkprint = lkeval.eval();
lkprint.out.output().join("\n") 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")
}
+3 -5
View File
@@ -4,13 +4,11 @@ extern crate hel;
use wasm_bindgen::prelude::*; use wasm_bindgen::prelude::*;
#[wasm_bindgen]
pub fn ok_add(a: i32, b: i32) -> i32 {
a + b + 1
}
mod hel_state; 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] #[wasm_bindgen]
pub fn hel_init() { pub fn hel_init() {
console_error_panic_hook::set_once();
} }
+347
View File
@@ -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; } }