P2 slice 6: the viewer
`wanderbooks gui [BOOK]` finds the data root, reads settings from the journal, the vault of the book, presence through ops/, Save a copy / Open on every document, Save the W-9, the Export menu, and colour schemes. One builder for the CPA package and the 1099 worksheet (ledger-package), shared by the server and the viewer. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
This commit is contained in:
1 parent
b26c099a68
commit
5bc0633cdd
77 files changed
+5833
-2427
No files matched your search
@@ -3,7 +3,7 @@
|
||||
//!
|
||||
//! ```text
|
||||
//! wanderbooks new [DIR] [--codex] [--force] [--no-start] (alias: init)
|
||||
//! wanderbooks gui [DIR | viewer flags]
|
||||
//! wanderbooks gui [BOOK | viewer flags]
|
||||
//! wanderbooks dev-stdio [server flags]
|
||||
//! wanderbooks setup [--root PATH] [--home DIR]
|
||||
//! ```
|
||||
@@ -75,14 +75,13 @@ gui:
|
||||
set -u
|
||||
if just gui-status &>/dev/null; then echo "The viewer is already open."; exit 0; fi
|
||||
log="{{justfile_directory()}}/books-data/gui.log"
|
||||
nohup "@@LEDGER_GUI@@" --data-dir "{{justfile_directory()}}/books-data" \
|
||||
--books-toml "{{justfile_directory()}}/wanderbooks.toml" >"$log" 2>&1 </dev/null &
|
||||
nohup "@@LEDGER_GUI@@" --data-dir "{{justfile_directory()}}/books-data" >"$log" 2>&1 </dev/null &
|
||||
sleep 1
|
||||
if kill -0 $! 2>/dev/null; then echo "Viewer open (pid $!)."; else echo "The viewer didn't start:"; cat "$log"; exit 1; fi
|
||||
|
||||
# Open the viewer in the foreground (logs in the terminal; Ctrl-C closes it)
|
||||
gui-fg:
|
||||
"@@LEDGER_GUI@@" --data-dir "{{justfile_directory()}}/books-data" --books-toml "{{justfile_directory()}}/wanderbooks.toml"
|
||||
"@@LEDGER_GUI@@" --data-dir "{{justfile_directory()}}/books-data"
|
||||
|
||||
# Is the viewer open for these books? Prints open/closed; exits 0 only if open
|
||||
gui-status:
|
||||
@@ -385,13 +384,16 @@ books-data/wanderbooks.lock
|
||||
exports/
|
||||
# The background viewer's log (`just gui`).
|
||||
books-data/gui.log
|
||||
# The viewer's side channel (books.show's requests, its presence): not the
|
||||
# books, never journaled.
|
||||
books-data/ops/
|
||||
"#;
|
||||
|
||||
const START_PROMPT: &str = "Hi! These are brand-new books. Please set them up with me.";
|
||||
|
||||
fn print_usage() {
|
||||
eprintln!("usage: wanderbooks new [DIR] [--codex] [--force] [--no-start]");
|
||||
eprintln!(" wanderbooks gui [DIR | --data-dir PATH ...]");
|
||||
eprintln!(" wanderbooks gui [BOOK | --root PATH --book ID | --data-dir PATH ...]");
|
||||
eprintln!(" wanderbooks dev-stdio [--data-dir PATH ...]");
|
||||
eprintln!(" wanderbooks setup [--root PATH] [--home DIR]");
|
||||
eprintln!(" wanderbooks books list | create ID | adopt DATA-DIR --id ID");
|
||||
@@ -405,7 +407,7 @@ fn print_usage() {
|
||||
eprintln!(" wanderbooks serve | close | doctor");
|
||||
eprintln!();
|
||||
eprintln!(" new make a books directory and start the agent in it (alias: init)");
|
||||
eprintln!(" gui open the live, read-only view of a books directory");
|
||||
eprintln!(" gui open the live, read-only view of the books (a book of the data root)");
|
||||
eprintln!(" dev-stdio run the books server over stdio (`dev-stdio --help` for its flags)");
|
||||
eprintln!(" setup make the books' private folder (only your account can open it)");
|
||||
eprintln!(" books list, start or adopt books (`books --help`)");
|
||||
@@ -772,24 +774,14 @@ fn new(dir: &Path, force: bool, start: bool, agent: Agent) -> Result<(), String>
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The viewer's arguments for `wanderbooks gui ARGS`: a lone books
|
||||
/// directory (none means this one) becomes its `books-data/` and
|
||||
/// `wanderbooks.toml`, named explicitly as before the viewer moved into
|
||||
/// this executable, so a settings file made later is still followed;
|
||||
/// anything else is the viewer's own flags, passed as they are.
|
||||
/// The viewer's arguments for `wanderbooks gui ARGS` (plan 3.6): a lone
|
||||
/// word names the book of the data root to show (`--book`), never a
|
||||
/// folder; nothing, the root's book; anything else is the viewer's own
|
||||
/// flags, passed as they are.
|
||||
#[cfg(feature = "gui")]
|
||||
fn gui_args(args: Vec<String>) -> Vec<String> {
|
||||
match args.as_slice() {
|
||||
[] => gui_args(vec![".".into()]),
|
||||
[dir] if !dir.starts_with('-') => {
|
||||
let dir = Path::new(dir);
|
||||
vec![
|
||||
"--data-dir".into(),
|
||||
dir.join("books-data").display().to_string(),
|
||||
"--books-toml".into(),
|
||||
dir.join("wanderbooks.toml").display().to_string(),
|
||||
]
|
||||
}
|
||||
[book] if !book.starts_with('-') => vec!["--book".into(), book.clone()],
|
||||
_ => args,
|
||||
}
|
||||
}
|
||||
@@ -1131,23 +1123,10 @@ mod gui_args_tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_lone_books_dir_names_its_data_and_settings() {
|
||||
let dir = std::path::Path::new("some/books");
|
||||
assert_eq!(
|
||||
gui_args(v(&["some/books"])),
|
||||
v(&[
|
||||
"--data-dir",
|
||||
&dir.join("books-data").display().to_string(),
|
||||
"--books-toml",
|
||||
&dir.join("wanderbooks.toml").display().to_string(),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_dir_means_this_one_named_explicitly() {
|
||||
assert_eq!(gui_args(v(&[])), gui_args(v(&["."])));
|
||||
assert_eq!(gui_args(v(&[]))[0], "--data-dir");
|
||||
fn a_lone_word_names_a_book_never_a_folder() {
|
||||
assert_eq!(gui_args(v(&["acme"])), v(&["--book", "acme"]));
|
||||
assert_eq!(gui_args(v(&["."])), v(&["--book", "."]));
|
||||
assert_eq!(gui_args(v(&[])), v(&[]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -703,17 +703,20 @@ async fn a_book_in_the_root_keeps_its_vault_and_key_inside_its_own_folder() {
|
||||
std::fs::read_dir(book.join("vault")).unwrap().count() > 0,
|
||||
"the TIN wasn't sealed into the book's vault"
|
||||
);
|
||||
// A file with TINs is never written into the root.
|
||||
let err = call(
|
||||
// A file with TINs is handed over, never written loose in the root.
|
||||
let w = call(
|
||||
&c,
|
||||
"books.vendor_1099",
|
||||
json!({"year": 2026, "include_tins": true,
|
||||
"save_csv_to": home.root().join("x.csv").display().to_string()}),
|
||||
json!({"year": 2026, "include_tins": true, "csv": true}),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
home.assert_hidden(&err);
|
||||
assert!(!home.root().join("x.csv").exists());
|
||||
.unwrap();
|
||||
home.assert_hidden(&w.to_string());
|
||||
assert!(
|
||||
w["csv"]["download"].as_str().unwrap().contains(" ex_"),
|
||||
"{w}"
|
||||
);
|
||||
assert!(w["csv"].get("content_base64").is_none(), "{w}");
|
||||
drop(c);
|
||||
assert!(server.close().success());
|
||||
}
|
||||
@@ -960,32 +963,33 @@ async fn nothing_is_written_in_the_root_and_the_vault_stays_private() {
|
||||
home.create("acme", "USD");
|
||||
let c = connect(&home, "acme").await.unwrap();
|
||||
let book = home.root().join("books/acme");
|
||||
let err = call(
|
||||
call(
|
||||
&c,
|
||||
"books.export_cpa_package",
|
||||
json!({"as_of": "2026-10-18", "include_tins": false,
|
||||
"save_to": book.display().to_string()}),
|
||||
"books.add_account",
|
||||
json!({"code": "1000", "name": "Cash", "account_type": "asset"}),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
home.assert_hidden(&err);
|
||||
.unwrap();
|
||||
let got = call(
|
||||
&c,
|
||||
"books.export_cpa_package",
|
||||
json!({"as_of": "2026-10-18", "include_tins": false, "cash_accounts": ["1000"]}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
home.assert_hidden(&got.to_string());
|
||||
let zips: Vec<_> = std::fs::read_dir(&book)
|
||||
.unwrap()
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| e.file_name().to_string_lossy().ends_with(".zip"))
|
||||
.collect();
|
||||
assert!(zips.is_empty(), "a package was written into the root");
|
||||
// A TIN file written outside first: the vault folder it records into
|
||||
// is made private, and storing a TIN then works.
|
||||
let out = tempfile::Builder::new()
|
||||
.prefix("wb-srv-out-")
|
||||
.tempdir()
|
||||
.unwrap();
|
||||
// A TIN file made first: the vault folder it records into is made
|
||||
// private, and storing a TIN then works.
|
||||
let _ = call(
|
||||
&c,
|
||||
"books.vendor_1099",
|
||||
json!({"year": 2026, "include_tins": true,
|
||||
"save_csv_to": out.path().join("1099.csv").display().to_string()}),
|
||||
json!({"year": 2026, "include_tins": true, "csv": true}),
|
||||
)
|
||||
.await;
|
||||
if book.join("vault").exists() {
|
||||
@@ -2473,16 +2477,11 @@ async fn a_w9_and_a_file_holding_a_tin_go_by_upload_only_where_they_belong() {
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(e.contains("no upload"), "{e}");
|
||||
let out = home.dir.path().join("payee copy.pdf");
|
||||
let got = call(
|
||||
&c,
|
||||
"books.get_w9",
|
||||
json!({"party": "Payee", "save_to": out.to_str().unwrap()}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(got["file_name"], json!("payee-w9.pdf"));
|
||||
assert_eq!(std::fs::read(&out).unwrap(), w9);
|
||||
let got = call(&c, "books.get_w9", json!({"party": "Payee"}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(got["file"]["file_name"], json!("payee-w9.pdf"));
|
||||
assert_eq!(fetch_export(&home, &token, &got["file"]).await, w9);
|
||||
// The W-9's upload is gone; the refused file's waits to lapse.
|
||||
let left: Vec<String> = std::fs::read_dir(home.book_dir("acme").join("uploads"))
|
||||
.unwrap()
|
||||
@@ -2510,9 +2509,28 @@ async fn lapsed_uploads_are_swept_while_the_server_runs() {
|
||||
let mut grant: Value = serde_json::from_slice(&std::fs::read(&json).unwrap()).unwrap();
|
||||
grant["expires"] = json!(0);
|
||||
std::fs::write(&json, grant.to_string()).unwrap();
|
||||
// An export too (a file the books made), aged the same way.
|
||||
let made = call(&c, "books.vendor_1099", json!({"year": 2026, "csv": true}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(made["csv"]["download"].as_str().unwrap().contains(" ex_"));
|
||||
let exports = home.book_dir("acme").join("exports");
|
||||
for e in std::fs::read_dir(&exports).unwrap() {
|
||||
let p = e.unwrap().path();
|
||||
if p.extension().is_some_and(|x| x == "json") {
|
||||
let mut x: Value = serde_json::from_slice(&std::fs::read(&p).unwrap()).unwrap();
|
||||
x["expires"] = json!(0);
|
||||
std::fs::write(&p, x.to_string()).unwrap();
|
||||
}
|
||||
}
|
||||
let deadline = Instant::now() + Duration::from_secs(25);
|
||||
while std::fs::read_dir(&folder).unwrap().count() > 0 {
|
||||
assert!(Instant::now() < deadline, "the lapsed upload wasn't swept");
|
||||
while std::fs::read_dir(&folder).unwrap().count() > 0
|
||||
|| std::fs::read_dir(&exports).unwrap().count() > 0
|
||||
{
|
||||
assert!(
|
||||
Instant::now() < deadline,
|
||||
"a lapsed upload or export wasn't swept"
|
||||
);
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
}
|
||||
drop(c);
|
||||
@@ -2547,3 +2565,159 @@ async fn an_mcp_request_is_at_most_a_mebibyte() {
|
||||
drop(http);
|
||||
assert!(server.close().success());
|
||||
}
|
||||
|
||||
/// Fetch the export a tool handed over in `file` (its `download`
|
||||
/// command's id) with `token`: its bytes, checked against its sha256.
|
||||
async fn fetch_export(home: &Home, token: &str, file: &Value) -> Vec<u8> {
|
||||
let command = file["download"]
|
||||
.as_str()
|
||||
.unwrap_or_else(|| panic!("{file}"));
|
||||
let id = command
|
||||
.split(' ')
|
||||
.find(|w| w.starts_with("ex_"))
|
||||
.unwrap_or_else(|| panic!("{command}"));
|
||||
let r = mcp_http(home)
|
||||
.get(format!("http://localhost/b/acme/x/{id}"))
|
||||
.bearer_auth(token)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(r.status().as_u16(), 200, "{:?}", r.headers());
|
||||
let bytes = r.bytes().await.unwrap().to_vec();
|
||||
assert_eq!(
|
||||
file["sha256"],
|
||||
json!(ledger_store::journal::sha256_hex(&bytes))
|
||||
);
|
||||
assert_eq!(file["size_bytes"], json!(bytes.len()));
|
||||
bytes
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn a_file_the_books_make_is_fetched_by_its_own_token() {
|
||||
let home = Home::new("exports");
|
||||
let server = Server::start(&home);
|
||||
home.create("acme", "USD");
|
||||
let c = connect(&home, "acme").await.unwrap();
|
||||
let token = home.token("acme");
|
||||
call(
|
||||
&c,
|
||||
"books.add_party",
|
||||
json!({"name": "Member One", "roles": ["member"]}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
call(
|
||||
&c,
|
||||
"books.set_tin",
|
||||
json!({"party": "Member One", "tin": "620-37-1111", "tin_type": "ssn"}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
for (code, name, ty) in [("1000", "Cash", "asset"), ("3000", "Capital", "equity")] {
|
||||
call(
|
||||
&c,
|
||||
"books.add_account",
|
||||
json!({"code": code, "name": name, "account_type": ty}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
// The CPA package, TINs and all: handed over, never inline, never a
|
||||
// path; the command runs as this token.
|
||||
let got = call(
|
||||
&c,
|
||||
"books.export_cpa_package",
|
||||
json!({"as_of": "2026-10-18", "cash_accounts": ["1000"]}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
home.assert_hidden(&got.to_string());
|
||||
assert_eq!(got["tins_included"], json!(true));
|
||||
let file = &got["file"];
|
||||
assert_eq!(file["file_name"], json!("cpa-package-2026-10-18.zip"));
|
||||
assert_eq!(file["mime_type"], json!("application/zip"));
|
||||
assert!(file.get("content_base64").is_none(), "{file}");
|
||||
let command = file["download"].as_str().unwrap();
|
||||
assert!(
|
||||
command.starts_with("wanderbooks download --book acme --token test ex_")
|
||||
&& command.ends_with(" --to <a folder>"),
|
||||
"{command}"
|
||||
);
|
||||
assert!(file["expires_at"].as_str().unwrap().ends_with('Z'));
|
||||
// Fetched whole, as often as needed while it's kept.
|
||||
let zip = fetch_export(&home, &token, file).await;
|
||||
assert!(zip.starts_with(b"PK"), "a zip");
|
||||
assert_eq!(fetch_export(&home, &token, file).await, zip);
|
||||
let id = command.split(' ').find(|w| w.starts_with("ex_")).unwrap();
|
||||
let get = |bearer: Option<String>, what: String| {
|
||||
let http = mcp_http(&home);
|
||||
async move {
|
||||
let mut r = http.get(format!("http://localhost/b/acme/x/{what}"));
|
||||
if let Some(b) = bearer {
|
||||
r = r.bearer_auth(b);
|
||||
}
|
||||
r.send().await.unwrap()
|
||||
}
|
||||
};
|
||||
// Only its own token; a reader at that.
|
||||
let other = home.token_with("acme", "other", &["read"]);
|
||||
let r = get(Some(other), id.to_string()).await;
|
||||
assert_eq!(r.status().as_u16(), 403);
|
||||
assert_eq!(r.headers()[ledger_mcp::local::REFUSED_HEADER], "not_yours");
|
||||
let writer = home.token_with("acme", "writer", &["write"]);
|
||||
assert_eq!(
|
||||
get(Some(writer), id.to_string()).await.status().as_u16(),
|
||||
403
|
||||
);
|
||||
assert_eq!(get(None, id.to_string()).await.status().as_u16(), 401);
|
||||
let r = get(
|
||||
Some(token.clone()),
|
||||
"ex_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".into(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(r.status().as_u16(), 404);
|
||||
home.assert_hidden(&r.text().await.unwrap());
|
||||
// A token that may make an export but not read the books fetches its
|
||||
// own: the export is bound to it, whatever its scopes.
|
||||
let admin = home.token_with("acme", "cpa", &["admin"]);
|
||||
let ca = connect_with(&home, "acme", Some(admin.clone()))
|
||||
.await
|
||||
.unwrap();
|
||||
let w = call(&ca, "books.vendor_1099", json!({"year": 2026, "csv": true}))
|
||||
.await
|
||||
.unwrap();
|
||||
let csv = fetch_export(&home, &admin, &w["csv"]).await;
|
||||
assert!(csv.starts_with(b"vendor_id,name,"));
|
||||
drop(ca);
|
||||
// Each fetch is in the access log as an export, under its id.
|
||||
let log = call(
|
||||
&c,
|
||||
"books.access_log",
|
||||
json!({"action": "export", "limit": 20}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let records = log["records"].as_array().unwrap();
|
||||
assert_eq!(records.len(), 7, "{log}");
|
||||
assert!(records.iter().filter(|r| r["subject"] == json!(id)).count() >= 5);
|
||||
// Given back to be filed, it is refused: the server made it with TINs.
|
||||
let e = call(
|
||||
&c,
|
||||
"books.file_document",
|
||||
json!({"category": "tax", "file_name": "p.zip", "mime_type": "application/zip",
|
||||
"content_base64": b64(&zip), "sha256": ledger_store::journal::sha256_hex(&zip)}),
|
||||
)
|
||||
.await;
|
||||
assert!(zip.len() <= 256 * 1024, "small enough to give inline");
|
||||
let e = e.unwrap_err();
|
||||
assert!(e.contains("TIN"), "{e}");
|
||||
// Nothing loose in the book's folder: the export is in its own.
|
||||
let loose: Vec<String> = std::fs::read_dir(home.book_dir("acme"))
|
||||
.unwrap()
|
||||
.map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
|
||||
.filter(|n| n.ends_with(".zip") || n.ends_with(".csv") || n.ends_with(".pdf"))
|
||||
.collect();
|
||||
assert!(loose.is_empty(), "{loose:?}");
|
||||
drop(c);
|
||||
assert!(server.close().success());
|
||||
}
|
||||
@@ -108,7 +108,7 @@ fn each_mode_names_itself_in_its_usage() {
|
||||
let out = wanderbooks().arg("--help").output().unwrap();
|
||||
let err = String::from_utf8_lossy(&out.stderr);
|
||||
for line in [
|
||||
"wanderbooks gui [DIR",
|
||||
"wanderbooks gui [BOOK",
|
||||
"wanderbooks dev-stdio",
|
||||
"dev-stdio run the books server",
|
||||
] {
|
||||
@@ -123,7 +123,7 @@ fn the_viewer_names_itself_in_its_usage() {
|
||||
assert!(out.status.success());
|
||||
let text = String::from_utf8_lossy(&out.stdout);
|
||||
assert!(
|
||||
text.contains("usage: wanderbooks gui [--data-dir PATH]"),
|
||||
text.contains("usage: wanderbooks gui [--root PATH] [--book ID]"),
|
||||
"{text}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2278,6 +2278,23 @@ fn the_commands_given_run_as_the_agents_own_token() {
|
||||
);
|
||||
assert!(out.status.success(), "{out:?}");
|
||||
assert_eq!(std::fs::read(folder.join("r (1).pdf")).unwrap(), bytes);
|
||||
// A file the books make (an export) is saved the same way.
|
||||
let w = a
|
||||
.call("books.vendor_1099", json!({"year": 2026, "csv": true}))
|
||||
.unwrap();
|
||||
let out = run_given(
|
||||
&home,
|
||||
w["csv"]["download"].as_str().unwrap(),
|
||||
" --to <a folder>",
|
||||
&["--to".as_ref(), folder.as_os_str()],
|
||||
);
|
||||
assert!(out.status.success(), "{out:?}");
|
||||
let csv = std::fs::read(folder.join("1099-worksheet-2026.csv")).unwrap();
|
||||
assert_eq!(
|
||||
w["csv"]["sha256"],
|
||||
json!(ledger_store::journal::sha256_hex(&csv))
|
||||
);
|
||||
assert!(csv.starts_with(b"vendor_id,name,"));
|
||||
// A file whose name isn't text goes, and comes back into a folder
|
||||
// whose name isn't text.
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
|
||||
Reference in new issue
Block a user