From 3cf359a0cd12f1dbd41bd14501ed3dcd0506cf1a Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Tue, 20 Oct 2026 01:16:32 -0600 Subject: [PATCH] P2 slice 8: the viewer's menu, setup, no confinement (D-32) No Landlock, no seccomp, no helper: isolation is "same_user (advisory)". The lease socket keeps two intents (close, format-upgrade consent). The viewer holds the books open while it runs and gains a Books menu: New books, Back up now, Restore (preview, then confirm), Close, Connect Claude Code / Codex. New commands `wanderbooks books back-up` and `wanderbooks connect claude|codex`. The scaffold (`new`/`init` and its templates) is gone; install.sh needs no sudo. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj --- Cargo.lock | 3 - README.md | 16 +- crates/ledger-cli/Cargo.toml | 5 +- crates/ledger-cli/src/main.rs | 934 +++------------------ crates/ledger-cli/tests/init.rs | 458 ---------- crates/ledger-cli/tests/one_executable.rs | 14 + crates/ledger-cli/tests/open_while_held.rs | 260 +++++- crates/ledger-config/src/lib.rs | 5 +- crates/ledger-gui/src/app.rs | 5 + crates/ledger-gui/src/lib.rs | 1 + crates/ledger-gui/src/owner.rs | 436 ++++++++++ crates/ledger-gui/tests/owner_menu.rs | 407 +++++++++ crates/ledger-host/src/bridge.rs | 13 +- crates/ledger-host/src/commands.rs | 676 ++++++++++++--- crates/ledger-host/src/helper.rs | 337 -------- crates/ledger-host/src/host.rs | 55 -- crates/ledger-host/src/lease.rs | 63 +- crates/ledger-host/src/lib.rs | 11 +- crates/ledger-host/src/opener.rs | 49 +- crates/ledger-mcp/src/admin.rs | 35 + crates/ledger-mcp/src/local.rs | 35 + crates/ledger-mcp/src/tools/kept.rs | 5 + crates/ledger-mcp/tests/spec_kept.rs | 2 + docs/mcp-interface.md | 4 +- docs/spec/rules.md | 3 +- install.sh | 53 +- plan/served.md | 10 +- site/index.html | 12 +- 28 files changed, 1976 insertions(+), 1931 deletions(-) delete mode 100644 crates/ledger-cli/tests/init.rs create mode 100644 crates/ledger-gui/src/owner.rs create mode 100644 crates/ledger-gui/tests/owner_menu.rs delete mode 100644 crates/ledger-host/src/helper.rs diff --git a/Cargo.lock b/Cargo.lock index 4f74ee6..0b66913 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3825,8 +3825,6 @@ name = "ledger-cli" version = "0.1.0" dependencies = [ "base64 0.22.1", - "chrono", - "ledger-config", "ledger-core", "ledger-gui", "ledger-host", @@ -3840,7 +3838,6 @@ dependencies = [ "sha2 0.10.9", "tempfile", "tokio", - "toml 1.1.8+spec-1.1.0", ] [[package]] diff --git a/README.md b/README.md index a52fc9c..823133c 100644 --- a/README.md +++ b/README.md @@ -72,15 +72,17 @@ cargo run -p ledger-mcp -- --data-dir /tmp/books # MCP server over stdio cargo run -p ledger-gui -- --demo # the viewer, on a demo company ``` New books, one command (installs Rust and wanderbooks if needed, makes the -books dir with the MCP server registered for it, and starts the agent): +books' private folder, starts books called `acme` and lets Claude Code reach +them; nothing needs sudo): ```bash -curl -fsSL https://wanderbooks.ai/install.sh | sh -s -- ~/Books/Acme +curl -fsSL https://wanderbooks.ai/install.sh | sh -s -- acme ``` -Already installed: `wanderbooks new ~/Books/Acme` (add `--codex` to keep the -books with Codex instead of Claude Code). In a books dir, `just` -lists the shortcuts (`just gui`, `just agent`, `just backup`). +Already installed, three commands: `wanderbooks setup`, `wanderbooks books +create acme`, `wanderbooks connect claude --book acme` (or `connect codex`). +Then talk to the agent; `wanderbooks gui acme` shows the books, and its +Books menu backs them up, restores, starts new books and closes them. ## Developing @@ -100,7 +102,9 @@ crates/ledger-store/ # journal + SQLite projection + blob store crates/ledger-mcp/ # MCP server: 108 tools, resources, handbook, receipt policy crates/ledger-import/ # QBO CSV import, IIF/XLSX export crates/ledger-config/ # wanderbooks.toml company settings -crates/ledger-cli/ # `wanderbooks new`: one-command books dir + agent +crates/ledger-cli/ # `wanderbooks`: the one executable (every mode) +crates/ledger-host/ # the host, the agents' bridge, the owner's commands +crates/ledger-package/ # The CPA package and the 1099 worksheet (server and viewer) crates/ledger-gui/ # the viewer: read-only, live (egui) docs/ # requirements, architecture, oracle, parity, glossary plan/ # STATUS.md, roadmap.md diff --git a/crates/ledger-cli/Cargo.toml b/crates/ledger-cli/Cargo.toml index b2473de..92a0610 100644 --- a/crates/ledger-cli/Cargo.toml +++ b/crates/ledger-cli/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" edition.workspace = true license.workspace = true repository.workspace = true -description = "wanderbooks CLI: one command from nothing to a working books directory." +description = "wanderbooks: the one executable (viewer, books server, host, agents' bridge, owner's commands)." [[bin]] name = "wanderbooks" @@ -24,17 +24,14 @@ ledger-gui = { path = "../ledger-gui", optional = true } [dev-dependencies] base64 = "0.22" -chrono = "0.4" libc = "0.2" reqwest = "0.13" rmcp = { version = "3", features = ["client", "transport-streamable-http-client-reqwest"] } tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] } -ledger-config = { path = "../ledger-config" } ledger-core = { path = "../ledger-core" } ledger-vault = { path = "../ledger-vault" } serde_json = "1" sha2 = "0.10" -toml = "1" tempfile = "3" [lints] diff --git a/crates/ledger-cli/src/main.rs b/crates/ledger-cli/src/main.rs index 28b1fb3..d542c39 100644 --- a/crates/ledger-cli/src/main.rs +++ b/crates/ledger-cli/src/main.rs @@ -1,402 +1,24 @@ -//! `wanderbooks` CLI: one command from nothing to talking with your -//! bookkeeper. +//! `wanderbooks`: the one executable (plan D-22). Its modes are the +//! viewer (`gui`), the books server (`server`, and `dev-stdio` over stdio), +//! the host and the agents' bridge (`host`, `mcp`), and the owner's +//! commands (`setup`, `books`, `connect`, `token`, `passphrase`, `serve`, +//! `close`, `doctor`, `upload`, `download`). A build without the `gui` +//! feature (the server-only build for another machine) has no viewer. //! -//! ```text -//! wanderbooks new [DIR] [--codex] [--force] [--no-start] (alias: init) -//! wanderbooks gui [BOOK | viewer flags] -//! wanderbooks dev-stdio [server flags] -//! wanderbooks setup [--root PATH] [--home DIR] -//! ``` -//! -//! `wanderbooks` is the one executable (plan D-22): `gui` is the viewer and -//! `dev-stdio` the books server over stdio, run in this process. A build -//! without the `gui` feature (the server-only build for another machine) -//! has no viewer. -//! -//! `new` makes a complete books directory and starts the agent in it: -//! -//! 1. Finds `ledger-mcp` and `ledger-gui` next to this binary or on the -//! PATH, and installs them with cargo if they're missing. -//! 2. Writes `AGENTS.md` (the bookkeeper's operating contract, with a -//! `CLAUDE.md` that imports it), `wanderbooks.toml`, a `justfile`, -//! `.gitignore`, and `books-data/`. -//! 3. Registers the MCP server for this directory only: `.mcp.json`, plus -//! `.claude/settings.json` approving it, so Claude Code loads the tools -//! without `claude mcp add` or a prompt. Claude Code -//! (`.claude/settings.json`) and Codex (`.codex/hooks.json`) both get a -//! SessionStart hook that runs `just session-start`: the viewer check. -//! 4. `git init`s the directory (its history is the archive) and commits. -//! 5. Starts `claude` in the directory with the onboarding prompt, unless -//! `--no-start` or there's no terminal. With `--codex`, registers the -//! server with Codex (as `wanderbooks-`) and starts Codex. +//! From nothing to the first post: `wanderbooks setup`, `wanderbooks books +//! create ID`, `wanderbooks connect claude --book ID` (then talk to the +//! agent). use std::env; -use std::fs; -use std::path::{Path, PathBuf}; -use std::process::Command; - -/// Where `wanderbooks update` installs from. -const REPO_URL: &str = "https://git.swaits.com/swaits/wanderbooks"; - -/// The checkout this binary was built from, if it still exists. -fn repo_root() -> Option { - let raw = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../.."); - let root = raw.canonicalize().ok()?; - root.join("crates/ledger-mcp/Cargo.toml") - .exists() - .then_some(root) -} - -const JUSTFILE: &str = r#"# wanderbooks books dir. Run `just` to list recipes. -# Day to day you talk to the agent (`just agent`); the recipes are the -# handful of things you might do yourself. - -set shell := ["bash", "-cu"] - -# List recipes -default: - @just --list - -# Start a bookkeeping session with the agent (Claude Code) -agent: - claude - -# Start a bookkeeping session in Codex (run `just codex-setup` once first) -codex: - codex - -# Register these books' MCP server with Codex (once per machine) -codex-setup: - codex mcp add "@@CODEX_NAME@@" -- "@@LEDGER_MCP@@" --data-dir "{{justfile_directory()}}/books-data" - -# Open the live, read-only view of these books (in the background; once per books dir) -gui: - #!/usr/bin/env bash - 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" >"$log" 2>&1 /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" - -# Is the viewer open for these books? Prints open/closed; exits 0 only if open -gui-status: - @pgrep -f -- "(ledger-gui|wanderbooks gui) --data-dir {{justfile_directory()}}/books-data( |$)|wanderbooks gui {{justfile_directory()}}/?$" >/dev/null && echo open || { echo closed; exit 1; } - -# The agents' session-start hook: the viewer's state, handled per [viewer] open_at_start -session-start: - #!/usr/bin/env bash - set -u - just gui-status &>/dev/null && { echo "wanderbooks viewer: open."; exit 0; } - pref=$(sed -n 's/^open_at_start *= *"\([a-z]*\)".*/\1/p' "{{justfile_directory()}}/wanderbooks.toml") - case "${pref:-ask}" in - never) ;; - always) echo "wanderbooks viewer: closed; open_at_start = \"always\", so it was opened: $(just gui 2>&1 | tail -1)" ;; - *) echo "wanderbooks viewer: CLOSED. [viewer] open_at_start = \"ask\": at the top of your first reply, before anything else, offer to open it (\`just gui\`)." ;; - esac - -# Commit the journal: the git history of this dir is the archive -backup: - git add -A && (git commit -qm "books backup $(date -u +%Y-%m-%dT%H:%M:%SZ)" && echo committed || echo "nothing to commit") - -# Update wanderbooks (server, GUI, CLI) to the latest version -update: - cargo install --locked --git @@REPO_URL@@ ledger-cli ledger-mcp ledger-gui -"#; - -const AGENTS_MD: &str = r#"# AGENTS.md — wanderbooks books dir - -This directory is a **wanderbooks** books dir: AI-native double-entry -bookkeeping. You (the AI agent) are the bookkeeper. The human talks to you; -you drive the books through the `wanderbooks` MCP server (registered for -this directory in `.mcp.json`). The humans read the books in the GUI -(`just gui`): read-only and live, so whatever you post appears there within -a second. Everything else they see, you show them. - -## The MCP contract - -- **Amounts are decimal strings** (`"123.45"`). Never floats, never - locale-formatted. Money is exact or it is wrong. -- **Draft → add lines → preview → confirmed post.** `post_entry` without - confirmation returns a preview and posts nothing: show the preview to the - human before confirming. Same pattern for `reverse_entry`. -- **Every entry balances.** Debits equal credits exactly. No epsilon. -- **Posted is forever.** Corrections are reversing entries linked to the - original, never edits, never deletes. -- **Receipts:** attach with `attach_receipt` and link to the entry. Anything - over the receipt threshold ($25 unless `wanderbooks.toml` says otherwise) - requires a receipt — the server enforces this, don't argue with it. One - file per receipt: PDF preferred, an image is fine. -- **Show the numbers.** After every mutation, show the affected balances. - When the human questions a figure, trace it to journal entries with the - tools (`books.get_entry`, `books.search_entries`). -- **Only through the tools.** The books are reached only through the - wanderbooks MCP tools. Never open, list or parse the books' files on - disk (journal, blobs, database, vault), not even to look. If a tool - can't answer, say so. - -## First run: onboarding - -If the books are empty (`books.chart` lists no accounts), onboard before -recording anything. Keep it conversational; ask a few things at a time. - -1. Ask: company legal name, entity type (sole proprietorship or - single-member LLC, partnership or multi-member LLC, S corp, C corp), - owners and their shares, fiscal year start, and whether they want - accrual or cash basis. -2. Propose a chart of accounts with `books.chart_template` for that entity - type and those owners. Walk the human through it, adjust, then import it - with `books.import_chart_csv`. Put the template's settings (members, - tax form, close target) in `wanderbooks.toml`. -3. Fill in `wanderbooks.toml` `[company]`: name, entity type, tax - classification, formation state and date, EIN, address. Don't store - SSNs or full account numbers anywhere in this directory. The server - reloads the file on every change: check `books.settings` after each - edit (no `reload_error`, no `problems`). - If the imported chart numbers receivables, payables, revenue, payroll, - or retained earnings differently from the defaults (`books.roles`), - assign those roles to its accounts with `books.assign_role`. -4. Record opening balances as one balanced opening entry (or the owners' - first contributions). Verify the trial balance before moving on. -5. File the formation documents they have (articles, EIN letter, - operating agreement) with `books.file_document`. -6. If they're migrating from another system, import from wherever the books - live now — QBO/CSV, bank or card CSVs, email receipts, a spreadsheet, - whatever they point you at. Diff trial balance, P&L, balance sheet, and - cash flow against the old system **to the cent** before calling the - migration done. -7. Tell them about `just gui`, then commit with `just backup`. - -## Receipts from email - -If the human has connected their email (a Gmail or Outlook connector, or a -browser extension with their mail open), find receipts there yourself: -search for the vendor and amount around the transaction date, then file -one file per receipt — the PDF attachment if there is one; otherwise save -the message and render it to PDF. Never file a raw `.eml`. Ask before -downloading anything, and never type a password or account number into a -form. - -## Switching from QuickBooks, Xero, or a spreadsheet - -1. Ask the human to export, from the old system, for every year they want - to bring over: the chart of accounts, the general journal (or general - ledger), and the trial balance, profit and loss, and balance sheet. CSV - or XLSX. Receipts usually live as attachments; export those too. -2. Set up the chart (`books.import_chart_csv` or account by account), then - rebuild the history as entries — or, if they only want a fresh start, one - opening entry from the old trial balance at the cutover date. -3. Diff your trial balance, P&L, balance sheet, and cash flow against the - old system's reports for the same dates, **to the cent**, and show the - human the comparison. Not done until every number matches. -4. Keep the old system read-only until a full month has been kept in - wanderbooks and closed cleanly. Then they can cancel it. - -Bank feeds aren't automatic here: import the bank's CSV export -(`books.import_bank_csv`, drafts to review) or read their statements. - -## Working with the CPA - -- At year end (or whenever the CPA asks), build the package with - `books.export_cpa_package`: every statement as PDF and XLSX, the tax - summary, the general ledger and journal, a QuickBooks Desktop IIF - journal, the 1099 worksheet, and every receipt with an index. Save it - where the human can send it. -- Before that: every expense and revenue account has a tax category - (`books.tax_categories`), the period's entries all have receipts, and - `books.verify` is clean. -- A CPA who wants to look around can get a read-only copy of this - directory (it's a git repository: push it to a private remote and give - them read access) and open it with `wanderbooks gui`. Nothing they do - there can change the books. - -## Session start - -A SessionStart hook runs `just session-start` and hands you the result; -if it didn't, run `just gui-status` yourself. If the viewer isn't open, follow `[viewer] -open_at_start` in `wanderbooks.toml`: `"ask"` (the default) offers to open -it, `"always"` opens it with `just gui`, `"never"` leaves it. `just gui` -starts it in the background and does nothing if it's already open. If the -human tells you their preference, write it there. - -## Ongoing - -- Every transaction: draft, preview, confirm with the human, post, attach - the receipt. Tag payments to contractors with their vendor (1099s). -- The human usually has the viewer open beside you (`just gui`). When they - ask to see something, and after you post, put it on their screen with - `books.show` (an entry, an account register, a report, a document, or a - search). -- `books.digest` is the review surface — "what did you do with my books - lately." Offer it; don't make the human ask. -- Reports are pure functions of the journal. If a number looks wrong, - trace it through the tools: `books.get_entry`, `books.search_entries`, - or `books.export` with report `journal` or `general_ledger`. Don't guess. -- Integrity: `books.verify` checks the journal's hash chain, the database - against the journal (rebuilding it if it drifted), and every stored - file. Run it when a number looks wrong or the human asks. -- Back up: `just backup` after every session that posted entries. The git - history of this directory is the archive; the journal is the system of - record and the database is disposable. -"#; - -const CLAUDE_MD: &str = "@AGENTS.md\n"; - -const WANDERBOOKS_TOML: &str = r#"# wanderbooks books dir config. [company] is filled in during onboarding — -# the agent asks, the human answers, the agent writes it here. -# -# Every setting is optional. A commented-out line is an example to fill in -# (uncomment it); a live line is the default. The MCP server and the -# viewer reload this file when it changes: no restart needed. - -[company] -# Legal name, for report headers. -# name = "Acme Widgets LLC" -# Shown in the profile. The journal's currency is fixed when the books are -# created (books-data/config.json base_currency, default USD). -base_currency = "USD" -# "MM-01": a fiscal year starts on the first of a month and ends the day -# before the next start (it ends on the last day of a month: IRS Pub. 538). -fiscal_year_start = "01-01" -# The return the business files: "schedule_c", "1065", "1120s", or "1120". -# tax_form = "1065" -# Or the federal classification, and the return follows from it: -# "partnership", "S corporation", "C corporation", or "disregarded entity" -# (Treas. Reg. §301.7701-3). tax_form wins when both are set. -# tax_classification = "partnership" -# The company's time zone (an IANA name): what "today" means for the -# books. Unset: the computer's. -# timezone = "America/Denver" -# Identity facts the CPA needs, each from a primary document (EIN letter, -# certificate of organization). Any simple key = value line here shows in -# the viewer's profile, labelled from its key. -# entity_type = "LLC, multi-member, member-managed" -# formation_state = "UT" -# formed = "2026-01-15" -# ein = "00-0000000" -# address = "123 Main St, Salt Lake City, UT 84101" -# Never store owners' SSNs (or any TIN) in this directory: its git history -# keeps everything. The CPA collects them through their own channel. - -# Members of a multi-member LLC, one block each: shares come from the -# operating agreement (IRC §704(a)), "50%" or "0.5", summing to 100%. Each -# names a capital account in the chart, and optionally a distributions -# account that a close zeroes into capital. Spouses who file one joint -# return each name the other in files_jointly_with (each still gets their -# own K-1; the CPA package notes both go on one Form 1040). -# [[company.members]] -# name = "Alex Rivera" -# share = "50%" -# capital_account = "3000" -# distribution_account = "3100" -# files_jointly_with = "Sam Chen" -# -# [[company.members]] -# name = "Sam Chen" -# share = "50%" -# capital_account = "3001" -# distribution_account = "3101" -# files_jointly_with = "Alex Rivera" - -[books] -# The books' data, relative to this file. The tools take it from --data-dir -# (the justfile, .mcp.json), not from here: move them together. -data_dir = "books-data" -# "accrual" or "cash", for report footers. Use the same method from year -# to year (IRS Pub. 538). -accounting_basis = "accrual" -# An entry whose debits are strictly over this needs a receipt; "0.00" -# means every entry (IRS Pub. 583: keep the documents that support your -# books). A receipt_policy in books-data/config.json overrides it. -receipt_threshold = "25.00" -# Accounts whose entries always need a receipt, whatever the amount; and -# accounts exempt from the rule (an entry whose every line is in one). -# receipt_required_accounts = ["6200"] -# receipt_exempt_accounts = ["6999"] -# Account codes that hold cash (bank, savings, cash on hand), for the cash -# flow statement and dashboards. Unset: cash accounts are found by name. -# cash_accounts = ["1000", "1010"] -# Liabilities the cash flow statement treats as financing: loans, and what -# owners advanced by paying company expenses (ASC 230-10-45-14(b)). -# financing_accounts = ["2100"] -# Assets it treats as investing: loans the company makes, investments it -# buys (ASC 230-10-20). Registered fixed assets need not be listed. -# investing_accounts = ["1400"] -# Single owner only (with [[company.members]] a close goes to each -# member's capital): the equity account a period close puts net income in. -# Unset: Retained Earnings (3999). -# close_into = "3000" -# Equity accounts a close zeroes into close_into (or Retained Earnings): -# draws, distributions, dividends. -# close_sweep = ["3100"] - -# The prefix of generated asset tags (the labels on equipment: FL-0001, -# FL-0002, ...). Unset: the company name's initials, without LLC/Inc -# ("Fernhill Labs LLC" -> FL); 1 to 8 letters or digits. -# asset_tag_prefix = "FL" - -# Whether entries name their counterparty (the party paid or paid by): -# "suggest" (the default) has the agent propose one when an entry has none; -# "require" refuses to post until it names one or is marked as having none -# (a transfer between your own accounts); "off" never mentions it. -# parties = "suggest" - -# Approval rules (conventions naming who approves which entries) block a -# post until approved only under "enforce"; "off" (the default) records -# them and blocks nothing. -# approval_rules = "off" - -# Which accounts invoices, bills, payroll, and closes post to (receivables, -# payables, revenue, payroll, retained earnings) is not set here: those -# roles are part of the books, journaled with books.assign_role and listed -# by books.roles. Until assigned, each uses its default code (1200, 2000, -# 4000, 6999, 3999, 6100, 6110, 2110-2150). - -[vault] -# The TIN vault (full taxpayer identification numbers and W-9s, encrypted, -# in vault/ beside the books, which git ignores) is opened with this key -# file, kept OUTSIDE the books. Keep a copy in your password manager: -# without it the vault can't be read. Unset: ~/.config/wanderbooks/vault.key -# key_file = "~/.config/wanderbooks/vault.key" - -[viewer] -# When a session starts and the viewer isn't open (`just gui-status`), the -# agent: "ask" offers to open it, "always" opens it (`just gui`), "never" -# leaves it closed. -open_at_start = "ask" -"#; - -const GITIGNORE: &str = r#"# The SQLite projection is disposable — it rebuilds from journal.jsonl. -books-data/wanderbooks.db -books-data/wanderbooks.db-* -# The TIN vault: encrypted, but never in git (it must be purgeable). -vault/ -# Copies of a database that disagreed with the journal, kept when it is -# rebuilt; disposable like the database itself. -books-data/wanderbooks.mismatch.* -# Held while an agent's MCP server has the books open: one writer at a time. -books-data/wanderbooks.lock -# Generated exports can always be regenerated from the journal. -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."; +use std::path::PathBuf; fn print_usage() { - eprintln!("usage: wanderbooks new [DIR] [--codex] [--force] [--no-start]"); + eprintln!("usage: wanderbooks setup [--root PATH] [--home DIR]"); 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"); + eprintln!( + " wanderbooks books list | create ID | adopt DATA-DIR --id ID | restore BACKUP | back-up ID" + ); + eprintln!(" wanderbooks connect claude|codex --book ID"); eprintln!( " wanderbooks token create --book ID | list | revoke --book ID | print --book ID" ); @@ -405,373 +27,21 @@ fn print_usage() { eprintln!(" wanderbooks upload --book ID [--token NAME] UPLOAD [--] FILE"); eprintln!(" wanderbooks download --book ID [--token NAME] ATTACHMENT --to FOLDER"); eprintln!(" wanderbooks serve | close | doctor"); + eprintln!(" wanderbooks dev-stdio [--data-dir PATH ...]"); eprintln!(); - eprintln!(" new make a books directory and start the agent in it (alias: init)"); - 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`)"); + eprintln!(" gui open the live, read-only view of the books (a book of the data root)"); + eprintln!(" books list, start, adopt, restore or back up books (`books --help`)"); + eprintln!(" connect let Claude Code or Codex reach the books"); eprintln!(" token make, list or revoke an agent's token for books (`token --help`)"); - eprintln!(" mcp connect an agent to books, over stdio (what `claude mcp add` runs)"); + eprintln!(" mcp an agent's way to the books, over stdio (what `connect` registers)"); eprintln!(" passphrase set or change the owner passphrase (typed at a terminal)"); eprintln!(" upload send a file for an upload an agent began (books.begin_upload)"); eprintln!(" download save a copy of a stored file (one too large to come back inline)"); eprintln!(" serve hold the books open until Ctrl-C"); eprintln!(" close close the books now: everything holding them lets go"); eprintln!(" doctor whether the books are open, what holds them, what went wrong"); - eprintln!(" DIR the books directory (default: current directory)"); - eprintln!(" --force rewrite the scaffold in an existing books directory"); - eprintln!(" --no-start don't start the agent afterwards"); - eprintln!(" --codex keep the books with Codex instead of Claude Code"); -} - -/// A binary next to this one (cargo installs them side by side), else on -/// the PATH. -fn find_binary(name: &str) -> Option { - let name = format!("{name}{}", env::consts::EXE_SUFFIX); - let name = name.as_str(); - if let Some(dir) = env::current_exe() - .ok() - .and_then(|e| e.parent().map(Path::to_path_buf)) - { - let p = dir.join(name); - if p.is_file() { - return Some(p); - } - } - env::var_os("PATH").and_then(|paths| { - env::split_paths(&paths) - .map(|d| d.join(name)) - .find(|p| p.is_file()) - }) -} - -/// Install the server and GUI with cargo: from this checkout when there is -/// one, else from the repo. -fn install_binaries() -> Result<(), String> { - let mut cmd = Command::new("cargo"); - cmd.args(["install", "--locked"]); - match repo_root() { - Some(root) => { - eprintln!( - "Installing ledger-mcp and ledger-gui from {} (a few minutes, once)…", - root.display() - ); - // `--path` takes one crate at a time. - for c in ["ledger-mcp", "ledger-gui"] { - let ok = Command::new("cargo") - .args(["install", "--locked", "--path"]) - .arg(root.join("crates").join(c)) - .status() - .map_err(|e| { - format!("can't run cargo: {e} (install Rust from https://rustup.rs)") - })? - .success(); - if !ok { - return Err(format!("cargo install of {c} failed")); - } - } - return Ok(()); - } - None => { - eprintln!( - "Installing ledger-mcp and ledger-gui from {REPO_URL} (a few minutes, once)…" - ); - cmd.args(["--git", REPO_URL, "ledger-mcp", "ledger-gui"]); - } - } - let ok = cmd - .status() - .map_err(|e| format!("can't run cargo: {e} (install Rust from https://rustup.rs)"))? - .success(); - if ok { - Ok(()) - } else { - Err("cargo install failed".into()) - } -} - -fn binaries() -> Result<(PathBuf, PathBuf), String> { - if let (Some(m), Some(g)) = (find_binary("ledger-mcp"), find_binary("ledger-gui")) { - return Ok((m, g)); - } - install_binaries()?; - match (find_binary("ledger-mcp"), find_binary("ledger-gui")) { - (Some(m), Some(g)) => Ok((m, g)), - _ => Err( - "installed, but ledger-mcp/ledger-gui aren't on the PATH; add ~/.cargo/bin to it" - .into(), - ), - } -} - -fn json_string(s: &str) -> String { - let mut out = String::from("\""); - for c in s.chars() { - match c { - '"' => out.push_str("\\\""), - '\\' => out.push_str("\\\\"), - '\n' => out.push_str("\\n"), - c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)), - c => out.push(c), - } - } - out.push('"'); - out -} - -/// `.mcp.json`: the wanderbooks server for this directory only. -fn mcp_json(ledger_mcp: &Path, books: &Path) -> String { - format!( - "{{\n \"mcpServers\": {{\n \"wanderbooks\": {{\n \"command\": {},\n \"args\": [\"--data-dir\", {}]\n }}\n }}\n}}\n", - json_string(&ledger_mcp.to_string_lossy()), - json_string(&books.join("books-data").to_string_lossy()) - ) -} - -/// `.claude/settings.json`: approve this directory's server, and its tools, -/// so the first session starts working instead of asking. The server still -/// previews every posting for the human to confirm. A SessionStart hook -/// runs `just session-start`, so the viewer check doesn't hang on the agent -/// remembering AGENTS.md. -const CLAUDE_SETTINGS: &str = r#"{ - "enabledMcpjsonServers": ["wanderbooks"], - "permissions": { - "allow": ["mcp__wanderbooks"] - }, - "hooks": { - "SessionStart": [ - { - "matcher": "startup|resume|clear", - "hooks": [ - { "type": "command", "command": "cd \"$CLAUDE_PROJECT_DIR\" && just session-start" } - ] - } - ] - } -} -"#; - -/// `.codex/hooks.json`: the same session-start check for Codex. Codex runs a -/// project hook only once the project is trusted and the hook approved -/// (`/hooks`); until then AGENTS.md's "Session start" is the fallback. -const CODEX_HOOKS: &str = r#"{ - "hooks": { - "SessionStart": [ - { - "matcher": "startup|resume|clear", - "hooks": [ - { "type": "command", "command": "cd \"$(git rev-parse --show-toplevel)\" && just session-start", "timeout": 10 } - ] - } - ] - } -} -"#; - -/// `git init` the dir unless already inside a repo; commit the scaffold. -/// Failures are warnings, not errors — the scaffold is what matters. -fn git_bootstrap(dir: &Path) { - let git = |args: &[&str]| { - Command::new("git") - .args(args) - .current_dir(dir) - .output() - .is_ok_and(|o| o.status.success()) - }; - if !git(&["rev-parse", "--is-inside-work-tree"]) && !git(&["init", "-q"]) { - eprintln!("warning: git init failed; skipping the first commit"); - return; - } - if !git(&["add", "-A"]) { - eprintln!("warning: git add failed; skipping the first commit"); - return; - } - // Use the person's identity when they have one; fall back so a fresh - // machine still gets a first commit. - let has_identity = git(&["config", "user.email"]); - let mut args = Vec::new(); - if !has_identity { - args.extend([ - "-c", - "user.name=wanderbooks", - "-c", - "user.email=wanderbooks@localhost", - ]); - } - args.extend(["commit", "-qm", "init: wanderbooks books dir"]); - git(&args); -} - -/// Write the books directory. Returns it, canonical. -fn scaffold( - dir: &Path, - force: bool, - ledger_mcp: &Path, - ledger_gui: &Path, -) -> Result { - fs::create_dir_all(dir).map_err(|e| format!("cannot create {}: {e}", dir.display()))?; - let dir = dir - .canonicalize() - .map_err(|e| format!("cannot resolve {}: {e}", dir.display()))?; - if dir.join("wanderbooks.toml").exists() && !force { - return Err(format!( - "{} is already a books dir (wanderbooks.toml exists); use --force to rewrite the scaffold", - dir.display() - )); - } - let justfile = JUSTFILE - .replace("@@LEDGER_GUI@@", &ledger_gui.to_string_lossy()) - .replace("@@LEDGER_MCP@@", &ledger_mcp.to_string_lossy()) - .replace("@@CODEX_NAME@@", &codex_name(&dir)) - .replace("@@REPO_URL@@", REPO_URL); - let mut files: Vec<(&str, String)> = vec![ - ("justfile", justfile), - ("AGENTS.md", AGENTS_MD.to_string()), - ("CLAUDE.md", CLAUDE_MD.to_string()), - (".gitignore", GITIGNORE.to_string()), - (".mcp.json", mcp_json(ledger_mcp, &dir)), - ]; - // The company profile is the person's data: never overwrite it. - if !dir.join("wanderbooks.toml").exists() { - files.push(("wanderbooks.toml", WANDERBOOKS_TOML.to_string())); - } - for (name, content) in files { - fs::write(dir.join(name), content).map_err(|e| format!("cannot write {name}: {e}"))?; - } - fs::create_dir_all(dir.join(".claude")).map_err(|e| format!("cannot create .claude: {e}"))?; - fs::write(dir.join(".claude/settings.json"), CLAUDE_SETTINGS) - .map_err(|e| format!("cannot write .claude/settings.json: {e}"))?; - fs::create_dir_all(dir.join(".codex")).map_err(|e| format!("cannot create .codex: {e}"))?; - fs::write(dir.join(".codex/hooks.json"), CODEX_HOOKS) - .map_err(|e| format!("cannot write .codex/hooks.json: {e}"))?; - fs::create_dir_all(dir.join("books-data")) - .map_err(|e| format!("cannot create books-data: {e}"))?; - fs::write(dir.join("books-data/.gitkeep"), "") - .map_err(|e| format!("cannot write .gitkeep: {e}"))?; - git_bootstrap(&dir); - Ok(dir) -} - -fn is_terminal() -> bool { - use std::io::IsTerminal; - std::io::stdin().is_terminal() && std::io::stdout().is_terminal() -} - -/// Which agent keeps the books. -#[derive(Clone, Copy, PartialEq, Eq)] -enum Agent { - Claude, - Codex, -} - -impl Agent { - fn binary(self) -> &'static str { - match self { - Agent::Claude => "claude", - Agent::Codex => "codex", - } - } -} - -/// Codex keeps MCP servers in one global config, so each books dir gets -/// its own server name: `wanderbooks-`. -fn codex_name(dir: &Path) -> String { - let base: String = dir - .file_name() - .map(|n| n.to_string_lossy().to_lowercase()) - .unwrap_or_default() - .chars() - .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }) - .collect(); - let base = base.trim_matches('-'); - if base.is_empty() { - "wanderbooks".to_string() - } else { - format!("wanderbooks-{base}") - } -} - -/// Register this books dir's MCP server with Codex. -fn codex_register(dir: &Path, ledger_mcp: &Path) -> Result<(), String> { - let Some(codex) = find_binary("codex") else { - return Err("Codex isn't installed: https://developers.openai.com/codex".into()); - }; - let name = codex_name(dir); - let ok = Command::new(codex) - .args(["mcp", "add", &name, "--"]) - .arg(ledger_mcp) - .arg("--data-dir") - .arg(dir.join("books-data")) - .status() - .map_err(|e| format!("can't run codex: {e}"))? - .success(); - if ok { - println!("Registered the books with Codex as MCP server {name:?}."); - Ok(()) - } else { - Err(format!("codex mcp add {name} failed")) - } -} - -/// Replace this process with the agent in the books dir, onboarding. -fn start_agent(dir: &Path, agent: Agent) -> Result<(), String> { - let Some(bin) = find_binary(agent.binary()) else { - println!( - "{} isn't installed. Install it, then run `{}` in {}.", - if agent == Agent::Claude { - "Claude Code" - } else { - "Codex" - }, - agent.binary(), - dir.display() - ); - return Ok(()); - }; - println!("Starting your bookkeeper…"); - let mut cmd = Command::new(bin); - cmd.arg(START_PROMPT).current_dir(dir); - #[cfg(unix)] - { - use std::os::unix::process::CommandExt; - let err = cmd.exec(); - Err(format!("can't start {}: {err}", agent.binary())) - } - #[cfg(not(unix))] - { - cmd.status() - .map_err(|e| format!("can't start {}: {e}", agent.binary()))?; - Ok(()) - } -} - -fn new(dir: &Path, force: bool, start: bool, agent: Agent) -> Result<(), String> { - let (ledger_mcp, ledger_gui) = binaries()?; - let dir = scaffold(dir, force, &ledger_mcp, &ledger_gui)?; - if agent == Agent::Codex { - codex_register(&dir, &ledger_mcp)?; - } - println!("Books ready in {}", dir.display()); - println!(" just gui the live, read-only view of the books"); - println!( - " just {} a session with your bookkeeper", - if agent == Agent::Codex { - "codex" - } else { - "agent" - } - ); - println!(" just backup commit the journal"); - if agent == Agent::Codex { - println!("In Codex, trust this directory and approve its session-start hook (/hooks)."); - } - if start && is_terminal() { - start_agent(&dir, agent)?; - } else { - println!("Next: cd {} && {}", dir.display(), agent.binary()); - } - Ok(()) + eprintln!(" dev-stdio run the books server over stdio (`dev-stdio --help` for its flags)"); } /// The viewer's arguments for `wanderbooks gui ARGS` (plan 3.6): a lone @@ -786,10 +56,101 @@ fn gui_args(args: Vec) -> Vec { } } -/// `wanderbooks gui`: the viewer, in this process. +/// The viewer's owner (ledger-gui owner.rs): its Books menu, done as the +/// command line does it (the books server, the agents' own commands). +#[cfg(all(feature = "gui", unix))] +struct ViewerOwner(ledger_host::commands::Common); + +#[cfg(all(feature = "gui", unix))] +impl ledger_gui::owner::Owner for ViewerOwner { + fn close_books(&self) -> Result { + ledger_host::commands::close_books(&self.0) + } + + fn new_books(&self, id: &str, currency: &str) -> Result { + ledger_host::commands::new_books(&self.0, id, currency) + } + + fn back_up_now(&self, book: &str) -> Result, String> { + ledger_host::commands::back_up_now(&self.0, book, false) + } + + fn plan_restore( + &self, + source: &str, + id: Option, + replace: bool, + ) -> Result { + let p = ledger_host::commands::plan_restore(&self.0, source, id, replace, false)?; + Ok(ledger_gui::owner::RestorePlan { + source: p.source, + id: p.id, + replace: p.replace, + head: p.head, + lines: p.lines, + }) + } + + fn restore(&self, plan: &ledger_gui::owner::RestorePlan) -> Result, String> { + ledger_host::commands::carry_out_restore( + &self.0, + &ledger_host::commands::RestorePlan { + source: plan.source.clone(), + id: plan.id.clone(), + replace: plan.replace, + head: plan.head.clone(), + origin: None, + lines: plan.lines.clone(), + }, + ) + } + + fn connect(&self, agent: ledger_gui::owner::Agent, book: &str) -> Result, String> { + let agent = match agent { + ledger_gui::owner::Agent::Claude => "claude", + ledger_gui::owner::Agent::Codex => "codex", + }; + ledger_host::commands::connect_agent(&self.0, agent, book) + } +} + +/// `wanderbooks gui`: the viewer, in this process. For books of the data +/// root it holds them open while it runs, and its Books menu acts on them. #[cfg(feature = "gui")] fn run_gui(args: Vec) -> i32 { - match ledger_gui::run("wanderbooks gui", gui_args(args)) { + let args = gui_args(args); + #[cfg(unix)] + { + let value = |flag: &str| { + // The last given, as the viewer reads it. + args.iter() + .rposition(|a| a == flag) + .and_then(|i| args.get(i + 1)) + .cloned() + }; + // Only the viewer's own flags for books of the data root, each with + // its value: anything else (help, a mistake, an older books dir, + // the demo) holds nothing. + let mut known = true; + let mut rest = args.iter(); + while let Some(a) = rest.next() { + if !matches!(a.as_str(), "--root" | "--book" | "--today") || rest.next().is_none() { + known = false; + break; + } + } + let root = value("--root").map(PathBuf::from); + let book = value("--book"); + if known && ledger_gui::book_in_root(root.as_deref(), book.as_deref()).is_ok() { + let common = ledger_host::commands::Common { + root, + knobs: Default::default(), + }; + ledger_gui::owner::set_owner(std::sync::Arc::new(ViewerOwner(common.clone()))); + ledger_host::commands::hold_for_viewer(&common, book); + } + } + match ledger_gui::run("wanderbooks gui", args) { Ok(()) => 0, Err(e) => match e.downcast_ref::() { Some(exit) => exit.0, @@ -963,18 +324,18 @@ fn run_server(args: Vec) -> i32 { } /// The modes that open and hold the books (plan 3.9): the host, the -/// helper, the agents' bridge, and the owner's commands. +/// agents' bridge, and the owner's commands. #[cfg(unix)] fn run_local(mode: &str, args: Vec) -> i32 { let prog = format!("wanderbooks {mode}"); let result = match mode { "host" => ledger_host::host::run(&prog, args), - "helper" => ledger_host::helper::run(&prog, args), "mcp" => ledger_host::bridge::run(&prog, args), "serve" => ledger_host::commands::serve(&prog, args), "close" => ledger_host::commands::close(&prog, args), "doctor" => ledger_host::commands::doctor(&prog, args), "token" => ledger_host::commands::token(&prog, args), + "connect" => ledger_host::commands::connect(&prog, args), "passphrase" => ledger_host::commands::passphrase(&prog, args), _ => ledger_host::commands::books(&prog, args), }; @@ -1056,7 +417,7 @@ fn main() { Some("setup") => std::process::exit(run_setup(all.collect())), Some("server") => std::process::exit(run_server(all.collect())), Some( - mode @ ("host" | "helper" | "mcp" | "serve" | "close" | "books" | "doctor" | "token" + mode @ ("host" | "mcp" | "serve" | "close" | "books" | "doctor" | "token" | "connect" | "passphrase"), ) => { let mode = mode.to_string(); @@ -1064,53 +425,34 @@ fn main() { } _ => {} } - let mut dir: Option = None; - let mut force = false; - let mut start = true; - let mut agent = Agent::Claude; - let mut cmd: Option = None; - for arg in words { - match arg.as_str() { - "--help" | "-h" => { - print_usage(); - return; - } - "--force" => force = true, - "--no-start" => start = false, - "--codex" => agent = Agent::Codex, - s if s.starts_with('-') => { - eprintln!("unknown flag: {s}"); - print_usage(); - std::process::exit(2); - } - s if cmd.is_none() => cmd = Some(s.to_string()), - s if dir.is_none() => dir = Some(PathBuf::from(s)), - s => { - eprintln!("unexpected argument: {s}"); - print_usage(); - std::process::exit(2); - } - } + if words.first().is_some_and(|w| w == "--help" || w == "-h") { + print_usage(); + return; } - if let Some( - mode @ ("gui" | "dev-stdio" | "setup" | "server" | "host" | "helper" | "mcp" | "serve" - | "close" | "books" | "doctor" | "token" | "passphrase" | "upload" | "download"), - ) = cmd.as_deref() - { - eprintln!("`{mode}` must come first: wanderbooks {mode} [its own flags]"); + // The books-folder scaffold is gone (P2 slice 8): said plainly. + if let Some(gone @ ("new" | "init")) = words.first().map(String::as_str) { + eprintln!( + "`wanderbooks {gone}` is gone: books now live in a private folder. Start them with `wanderbooks setup`, `wanderbooks books create ID` and `wanderbooks connect claude --book ID`." + ); std::process::exit(2); } - let dir = dir.unwrap_or_else(|| PathBuf::from(".")); - let result = match cmd.as_deref() { - Some("new") | Some("init") => new(&dir, force, start, agent), + // A mode named after a flag: it must come first. + match words + .iter() + .map(String::as_str) + .find(|w| !w.starts_with('-')) + { + Some( + mode @ ("gui" | "dev-stdio" | "setup" | "server" | "host" | "mcp" | "serve" | "connect" + | "close" | "books" | "doctor" | "token" | "passphrase" | "upload" | "download"), + ) => { + eprintln!("`{mode}` must come first: wanderbooks {mode} [its own flags]"); + std::process::exit(2); + } _ => { print_usage(); std::process::exit(2); } - }; - if let Err(e) = result { - eprintln!("error: {e}"); - std::process::exit(1); } } diff --git a/crates/ledger-cli/tests/init.rs b/crates/ledger-cli/tests/init.rs deleted file mode 100644 index fab60f9..0000000 --- a/crates/ledger-cli/tests/init.rs +++ /dev/null @@ -1,458 +0,0 @@ -//! Integration tests for `wanderbooks new`: one command lands a complete -//! books dir with the MCP server registered for it, the TOML and JSON -//! parse, and re-running is refused without `--force` (which never -//! touches the company profile). - -use std::fs; -use std::path::{Path, PathBuf}; -use std::process::Command; - -fn bin() -> PathBuf { - PathBuf::from(env!("CARGO_BIN_EXE_wanderbooks")) -} - -/// Stand-ins for the installed binaries, so tests never run cargo install. -fn fake_bins(tmp: &Path) -> PathBuf { - let dir = tmp.join("bin"); - fs::create_dir_all(&dir).unwrap(); - for b in ["ledger-mcp", "ledger-gui"] { - fs::write(dir.join(b), "#!/bin/sh\n").unwrap(); - } - dir -} - -fn run(tmp: &Path, args: &[&str]) -> (bool, String) { - let path = format!( - "{}:{}", - fake_bins(tmp).display(), - std::env::var("PATH").unwrap_or_default() - ); - let out = Command::new(bin()) - .args(args) - .arg("--no-start") - .env("PATH", path) - .output() - .expect("run wanderbooks"); - let mut s = String::from_utf8_lossy(&out.stdout).into_owned(); - s.push_str(&String::from_utf8_lossy(&out.stderr)); - (out.status.success(), s) -} - -fn new(tmp: &Path, books: &Path, extra: &[&str]) -> (bool, String) { - let mut args = vec!["new", books.to_str().unwrap()]; - args.extend(extra); - run(tmp, &args) -} - -#[test] -fn scaffold_lands_complete() { - let tmp = tempfile::tempdir().unwrap(); - let books = tmp.path().join("acme-books"); - let (ok, out) = new(tmp.path(), &books, &[]); - assert!(ok, "new should succeed: {out}"); - for f in [ - "justfile", - "AGENTS.md", - "CLAUDE.md", - "wanderbooks.toml", - ".gitignore", - ".mcp.json", - ".claude/settings.json", - ".codex/hooks.json", - ] { - let p = books.join(f); - assert!(p.exists(), "{f} exists"); - assert!(!fs::read_to_string(&p).unwrap().is_empty(), "{f} non-empty"); - } - assert!(books.join("books-data").is_dir(), "books-data dir exists"); - assert!(books.join(".git").is_dir(), "fresh dir gets git init"); - let log = Command::new("git") - .args(["log", "--oneline"]) - .current_dir(&books) - .output() - .unwrap(); - assert!( - String::from_utf8_lossy(&log.stdout).contains("init"), - "scaffold committed" - ); -} - -#[test] -fn the_server_is_registered_for_this_dir() { - let tmp = tempfile::tempdir().unwrap(); - let books = tmp.path().join("b"); - assert!(new(tmp.path(), &books, &[]).0); - let books = books.canonicalize().unwrap(); - let mcp: serde_json::Value = - serde_json::from_str(&fs::read_to_string(books.join(".mcp.json")).unwrap()).unwrap(); - let server = &mcp["mcpServers"]["wanderbooks"]; - assert!(server["command"].as_str().unwrap().ends_with("ledger-mcp")); - assert_eq!( - server["args"], - serde_json::json!(["--data-dir", books.join("books-data").to_string_lossy()]) - ); - let settings: serde_json::Value = - serde_json::from_str(&fs::read_to_string(books.join(".claude/settings.json")).unwrap()) - .unwrap(); - assert_eq!( - settings["enabledMcpjsonServers"], - serde_json::json!(["wanderbooks"]) - ); - // Every session starts by checking the viewer against open_at_start. - let hook = settings["hooks"]["SessionStart"][0]["hooks"][0]["command"] - .as_str() - .expect("SessionStart hook command"); - assert!(hook.ends_with("just session-start"), "{hook}"); - let codex: serde_json::Value = - serde_json::from_str(&fs::read_to_string(books.join(".codex/hooks.json")).unwrap()) - .unwrap(); - let hook = codex["hooks"]["SessionStart"][0]["hooks"][0]["command"] - .as_str() - .expect("Codex SessionStart hook command"); - assert!(hook.ends_with("just session-start"), "{hook}"); - // The justfile opens this dir's books in the GUI. - let just = fs::read_to_string(books.join("justfile")).unwrap(); - assert!(just.contains("ledger-gui\" --data-dir \"{{justfile_directory()}}/books-data\"")); - for recipe in [ - "agent:", - "gui:", - "gui-fg:", - "gui-status:", - "session-start:", - "backup:", - "update:", - ] { - assert!(just.contains(recipe), "justfile has {recipe}"); - } - assert_eq!( - fs::read_to_string(books.join("CLAUDE.md")).unwrap().trim(), - "@AGENTS.md" - ); - let toml: toml::Value = - toml::from_str(&fs::read_to_string(books.join("wanderbooks.toml")).unwrap()).unwrap(); - assert_eq!(toml["books"]["data_dir"].as_str(), Some("books-data")); -} - -#[test] -fn agents_md_carries_the_contract() { - let tmp = tempfile::tempdir().unwrap(); - let books = tmp.path().join("b"); - assert!(new(tmp.path(), &books, &[]).0); - let md = fs::read_to_string(books.join("AGENTS.md")).unwrap(); - for phrase in [ - "preview", - "Posted is forever", - "reversing entries", - "receipt", - "onboard", - "chart_template", - "to the cent", - "just gui", - "just gui-status", - "open_at_start", - ] { - assert!(md.contains(phrase), "AGENTS.md mentions {phrase:?}"); - } -} - -/// The agent reaches the books only through the MCP tools. The scaffolded -/// AGENTS.md once said "the journal is the evidence — read it", and an -/// agent `cat`ed the journal file instead of asking the books. -#[test] -fn agents_md_reaches_the_books_only_through_tools() { - let tmp = tempfile::tempdir().unwrap(); - let books = tmp.path().join("b"); - assert!(new(tmp.path(), &books, &[]).0); - let md = fs::read_to_string(books.join("AGENTS.md")).unwrap(); - for phrase in [ - "only through the", - "wanderbooks MCP tools", - "Never open, list or parse", - "books.get_entry", - "books.search_entries", - "general_ledger", - "can't answer, say so.", - ] { - assert!(md.contains(phrase), "AGENTS.md says {phrase:?}"); - } - // Nothing points the agent at the books' files. - for path in ["books-data", "journal.jsonl", "wanderbooks.db", "read it"] { - assert!(!md.contains(path), "AGENTS.md never mentions {path:?}"); - } -} - -#[test] -fn rerun_refused_without_force_and_never_clobbers_the_profile() { - let tmp = tempfile::tempdir().unwrap(); - let books = tmp.path().join("b"); - assert!(new(tmp.path(), &books, &[]).0); - let (ok, out) = new(tmp.path(), &books, &[]); - assert!(!ok, "second new without --force fails"); - assert!(out.contains("--force"), "error points at --force: {out}"); - fs::write( - books.join("wanderbooks.toml"), - "[company]\nname = \"Acme LLC\"\n", - ) - .unwrap(); - let (ok, _) = new(tmp.path(), &books, &["--force"]); - assert!(ok, "second new with --force succeeds"); - assert!( - fs::read_to_string(books.join("wanderbooks.toml")) - .unwrap() - .contains("Acme LLC"), - "--force rewrites the scaffold, never the company profile" - ); -} - -#[test] -fn init_is_an_alias_and_respects_an_existing_repo() { - let tmp = tempfile::tempdir().unwrap(); - let books = tmp.path().join("b"); - fs::create_dir_all(&books).unwrap(); - assert!( - Command::new("git") - .args(["init", "-q"]) - .current_dir(&books) - .status() - .unwrap() - .success() - ); - let (ok, out) = run(tmp.path(), &["init", books.to_str().unwrap()]); - assert!(ok, "{out}"); - assert!(books.join("wanderbooks.toml").exists()); -} - -#[test] -fn codex_gets_its_own_named_server() { - let tmp = tempfile::tempdir().unwrap(); - let bin = fake_bins(tmp.path()); - let log = tmp.path().join("codex-args"); - // A stand-in codex that records how it was called. - fs::write( - bin.join("codex"), - format!("#!/bin/sh\necho \"$@\" > {}\n", log.display()), - ) - .unwrap(); - use std::os::unix::fs::PermissionsExt; - fs::set_permissions(bin.join("codex"), fs::Permissions::from_mode(0o755)).unwrap(); - let books = tmp.path().join("Acme Books"); - let (ok, out) = new(tmp.path(), &books, &["--codex"]); - assert!(ok, "{out}"); - let args = fs::read_to_string(&log).unwrap(); - let books = books.canonicalize().unwrap(); - assert!( - args.starts_with("mcp add wanderbooks-acme-books -- "), - "{args}" - ); - assert!( - args.trim_end().ends_with(&format!( - "--data-dir {}", - books.join("books-data").display() - )), - "{args}" - ); - let just = fs::read_to_string(books.join("justfile")).unwrap(); - assert!(just.contains("codex mcp add \"wanderbooks-acme-books\"")); - assert!(out.contains("just codex"), "{out}"); -} - -/// The `wanderbooks.toml` a fresh `new` writes. -fn scaffolded_toml(tmp: &Path) -> (PathBuf, String) { - let books = tmp.join("b"); - assert!(new(tmp, &books, &[]).0); - let path = books.join("wanderbooks.toml"); - let text = fs::read_to_string(&path).unwrap(); - (path, text) -} - -/// The example a commented line holds, if it is one: `# key = value`, or -/// `# [section]` / `# [[section]]`. Prose comments are not examples. -fn example(line: &str) -> Option<&str> { - let rest = line.strip_prefix("# ")?; - let is_key = |k: &str| !k.is_empty() && k.bytes().all(|b| b.is_ascii_lowercase() || b == b'_'); - let header = rest - .trim_start_matches('[') - .trim_end_matches(']') - .split('.') - .all(is_key); - let assignment = rest - .split_once(" = ") - .is_some_and(|(k, v)| is_key(k) && !v.trim().is_empty()); - ((rest.starts_with('[') && rest.ends_with(']') && header) || assignment).then_some(rest) -} - -/// The template with the examples `pick` chooses uncommented. -fn uncomment(text: &str, pick: impl Fn(&str) -> bool) -> String { - text.lines() - .map(|l| match example(l) { - Some(ex) if pick(ex) => ex, - _ => l, - }) - .collect::>() - .join("\n") -} - -#[test] -fn the_scaffolded_toml_loads_with_the_defaults() { - let tmp = tempfile::tempdir().unwrap(); - let (path, text) = scaffolded_toml(tmp.path()); - let s = ledger_config::load(&path).expect("scaffolded wanderbooks.toml loads"); - let c = &s.company; - assert_eq!(c.name, "Unnamed company", "name is left for onboarding"); - assert_eq!(c.fiscal_year_start, (1, 1)); - assert_eq!(c.basis, "Accrual"); - assert!(c.members.is_empty()); - assert_eq!(c.tax_form, None); - assert_eq!(c.close_into, None); - assert!(c.close_sweep.is_empty()); - assert_eq!( - c.profile, - [ - ("base_currency".to_string(), "USD".to_string()), - ("fiscal_year_start".to_string(), "01-01".to_string()), - ] - ); - assert_eq!(s.receipt_threshold.as_deref(), Some("25.00")); - assert!(s.cash_accounts.is_empty()); - assert!(s.financing_accounts.is_empty()); - assert!(s.investing_accounts.is_empty()); - let toml: toml::Value = toml::from_str(&text).unwrap(); - assert_eq!(toml["books"]["data_dir"].as_str(), Some("books-data")); - // The justfile's session-start reads this line with sed, from column 0. - assert!( - text.lines().any(|l| l == "open_at_start = \"ask\""), - "{text}" - ); - assert!(text.contains("SSN"), "the template warns off storing SSNs"); -} - -#[test] -fn every_example_in_the_toml_is_valid() { - let tmp = tempfile::tempdir().unwrap(); - let (_, text) = scaffolded_toml(tmp.path()); - let examples = text.lines().filter_map(example).count(); - assert!(examples >= 20, "only {examples} examples found"); - // close_into is for a single owner and refused with members, so it - // gets a pass of its own; together the two passes cover every example. - let single_owner = |ex: &str| ex.starts_with("close_into "); - let all = ledger_config::parse(&uncomment(&text, |ex| !single_owner(ex))) - .unwrap_or_else(|e| panic!("examples (without close_into) parse: {e}")); - let c = &all.company; - assert_eq!(c.name, "Acme Widgets LLC"); - assert_eq!(c.tax_form, Some(ledger_core::TaxForm::Form1065)); - assert_eq!(c.members.len(), 2); - assert_eq!(c.members[1].distribution_account.as_deref(), Some("3101")); - assert_eq!(c.close_sweep, ["3100"]); - assert!(c.profile.iter().any(|(k, _)| k == "ein")); - assert_eq!(all.cash_accounts, ["1000", "1010"]); - assert_eq!(all.financing_accounts, ["2100"]); - assert_eq!(all.investing_accounts, ["1400"]); - let single = ledger_config::parse(&uncomment(&text, single_owner)) - .unwrap_or_else(|e| panic!("close_into example parses: {e}")); - assert_eq!(single.company.close_into.as_deref(), Some("3000")); - assert!( - ledger_config::parse(&uncomment(&text, |_| true)).is_err(), - "close_into with members is refused, as the template says" - ); -} - -/// The serde field names of `struct name` in ledger-config's source. -fn fields(src: &str, name: &str) -> Vec { - let start = src - .find(&format!("struct {name} {{")) - .unwrap_or_else(|| panic!("ledger-config has struct {name}")); - let body = &src[start..]; - let body = &body[..body.find("\n}").expect("struct ends")]; - let found: Vec = body - .lines() - .skip(1) - .map(str::trim) - .filter(|l| !l.starts_with("//") && !l.starts_with("#[")) - .filter_map(|l| l.split_once(':')) - .map(|(k, _)| k.trim_start_matches("pub ").to_string()) - .collect(); - assert!(!found.is_empty(), "struct {name} has fields"); - found -} - -#[test] -fn the_toml_shows_every_setting_the_config_reads() { - let tmp = tempfile::tempdir().unwrap(); - let (_, text) = scaffolded_toml(tmp.path()); - // (section, key) for every assignment, examples included. - let mut section = String::new(); - let mut present = Vec::new(); - for line in uncomment(&text, |_| true).lines() { - if line.starts_with('[') { - section = line.trim_matches(['[', ']']).to_string(); - } else if let Some((k, _)) = line.split_once(" = ") { - present.push((section.clone(), k.to_string())); - } - } - let has = |s: &str, k: &str| present.iter().any(|(ps, pk)| ps == s && pk == k); - // Read from the config crate's serde structs, so a new setting there - // fails here until the template shows it. - let src = include_str!("../../ledger-config/src/lib.rs"); - assert_eq!(fields(src, "BooksToml"), ["company", "books", "vault"]); - for (strukt, sect) in [ - ("CompanyToml", "company"), - ("MemberToml", "company.members"), - ("BooksSection", "books"), - ("VaultSection", "vault"), - ] { - for key in fields(src, strukt) { - if strukt == "CompanyToml" && key == "members" { - assert!(text.contains("# [[company.members]]")); - continue; - } - assert!(has(sect, &key), "[{sect}] {key} is in the template"); - } - } - // Read elsewhere: the justfile's session-start (main.rs), and the two - // the scaffold has always written live. - for (sect, key) in [ - ("viewer", "open_at_start"), - ("company", "base_currency"), - ("books", "data_dir"), - ] { - assert!(has(sect, key), "[{sect}] {key} is in the template"); - } -} - -/// Moving the scaffolded settings file, and every example in it, into -/// books: each imports, reads as the file does (the import refuses -/// otherwise), and exporting it back and importing the export changes -/// nothing (plan/served.md P1: toml_round_trip_zero_events). -#[test] -fn the_toml_and_its_examples_import_and_round_trip() { - use ledger_core::settings::{Settings, Source}; - let tmp = tempfile::tempdir().unwrap(); - let (_, text) = scaffolded_toml(tmp.path()); - let single_owner = |ex: &str| ex.starts_with("close_into "); - let source = Source { - decided_by: "onboarding".into(), - decided_on: chrono_date(), - citation: None, - note: None, - }; - for file in [ - text.clone(), - uncomment(&text, |ex| !single_owner(ex)), - uncomment(&text, single_owner), - ] { - let import = ledger_config::codec::import(&file, None, &Settings::default(), "USD") - .unwrap_or_else(|e| panic!("imports: {e}\n{file}")); - assert!(import.unknown.is_empty(), "{:?}", import.unknown); - let mut books = Settings::default(); - let changes = books.check_changes(&import.changes, &source).unwrap(); - books.apply(&changes, &source, 1).unwrap(); - let export = ledger_config::codec::export(&books, "USD"); - let again = ledger_config::codec::import(&export, None, &books, "USD") - .unwrap_or_else(|e| panic!("the export imports: {e}\n{export}")); - assert!(again.changes.is_empty(), "{:?}\n{export}", again.changes); - } -} - -fn chrono_date() -> chrono::NaiveDate { - chrono::NaiveDate::from_ymd_opt(2026, 10, 17).unwrap() -} diff --git a/crates/ledger-cli/tests/one_executable.rs b/crates/ledger-cli/tests/one_executable.rs index 8992800..f6f4104 100644 --- a/crates/ledger-cli/tests/one_executable.rs +++ b/crates/ledger-cli/tests/one_executable.rs @@ -150,6 +150,20 @@ fn a_mode_refuses_what_it_does_not_know() { assert_eq!(out.status.code(), Some(2), "{out:?}"); } +#[test] +fn the_books_folder_scaffold_is_gone_and_says_so() { + for gone in ["new", "init"] { + let out = wanderbooks().args([gone, "acme"]).output().unwrap(); + assert_eq!(out.status.code(), Some(2), "{gone}: {out:?}"); + let err = String::from_utf8_lossy(&out.stderr); + assert!( + err.contains(&format!("`wanderbooks {gone}` is gone")), + "{err}" + ); + assert!(err.contains("wanderbooks books create ID"), "{err}"); + } +} + #[test] fn a_mode_comes_first() { for mode in ["gui", "dev-stdio", "server"] { diff --git a/crates/ledger-cli/tests/open_while_held.rs b/crates/ledger-cli/tests/open_while_held.rs index c358713..803dcd4 100644 --- a/crates/ledger-cli/tests/open_while_held.rs +++ b/crates/ledger-cli/tests/open_while_held.rs @@ -170,7 +170,7 @@ impl Drop for Home { let deadline = Instant::now() + Duration::from_secs(10); loop { let mut found = false; - for mode in ["serve", "mcp", "helper", "host", "server"] { + for mode in ["serve", "mcp", "host", "server"] { for pid in self.pids(mode) { found = true; // SAFETY: a process of this test's own root. @@ -922,19 +922,15 @@ fn the_lease_socket_takes_only_the_owners_intents() { ), ( "{\"proto\":1,\"intent\":{\"back_up_now\":{\"book\":\"acme\"}}}\n", - "books.back_up_now", + "not understood", ), ( - "{\"proto\":1,\"intent\":{\"restore\":{\"source\":\"/x\",\"id\":\"acme\",\"replace\":true}}}\n", - "wanderbooks books restore", + "{\"proto\":1,\"intent\":{\"restore\":{\"source\":\"/x\",\"id\":\"acme\"}}}\n", + "not understood", ), ( "{\"proto\":1,\"intent\":{\"new_books\":{\"id\":\"beta\",\"currency\":\"USD\"}}}\n", - "new books beta made", - ), - ( - "{\"proto\":1,\"intent\":{\"new_books\":{\"id\":\"beta\",\"currency\":\"USD\"}}}\n", - "already books called", + "not understood", ), ] { let answer = home.lease_line(line); @@ -946,7 +942,7 @@ fn the_lease_socket_takes_only_the_owners_intents() { } let out = home.books(&["list"]); assert!( - String::from_utf8_lossy(&out.stdout).contains("beta open"), + !String::from_utf8_lossy(&out.stdout).contains("beta"), "{out:?}" ); } @@ -1920,9 +1916,8 @@ fn the_books_command_takes_its_flags_and_asks_before_adopting() { /// A program that starts the bridge with its stderr closed (some launchers /// do) still gets a working bridge, its descriptor 2 on /dev/null: Rust's -/// runtime opens it before main, so the helper's socket can't take its -/// number and be sent the error messages (asked in slice 3's mutation -/// testing: the helper's descriptor 3 then is never its own pair's end). +/// runtime opens it before main, so no socket the bridge opens can take +/// its number and be sent the error messages. #[test] fn a_bridge_started_without_stderr_keeps_its_descriptors_apart() { use std::os::unix::process::CommandExt; @@ -2464,3 +2459,242 @@ fn a_backup_is_restored_from_the_command_line() { assert_eq!(start["head"]["seq"].as_u64(), Some(head), "{start}"); fresh.end(); } + +/// A stand-in for an agent's own command line (`claude`, `codex`): it +/// notes each call's arguments, a line each. +fn fake_agent(home: &Home, name: &str) -> (PathBuf, PathBuf) { + use std::os::unix::fs::PermissionsExt; + let bin = home.dir.path().join("agent-bin"); + std::fs::create_dir_all(&bin).unwrap(); + let log = home.dir.path().join(format!("{name}.log")); + let script = bin.join(name); + std::fs::write( + &script, + format!("#!/bin/sh\nprintf '%s\\n' \"$*\" >> '{}'\n", log.display()), + ) + .unwrap(); + std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap(); + (bin, log) +} + +#[test] +fn an_agent_is_connected_and_reaches_the_books_by_what_it_was_given() { + let home = Home::new("connect"); + let out = home.books(&["create", "beta"]); + assert!(out.status.success(), "{out:?}"); + let (bin, log) = fake_agent(&home, "claude"); + let connect = |agent: &str, path: &Path| { + home.cmd("connect") + .args([agent, "--book", "beta"]) + .env("PATH", format!("{}:/usr/bin:/bin", path.display())) + .stdin(Stdio::null()) + .output() + .unwrap() + }; + let out = connect("claude", &bin); + assert!(out.status.success(), "{out:?}"); + let said = String::from_utf8_lossy(&out.stdout); + assert!(said.contains("Made the books beta' agent token"), "{said}"); + assert!( + said.contains("Claude Code reaches the books beta"), + "{said}" + ); + let calls = std::fs::read_to_string(&log).unwrap(); + let lines: Vec<&str> = calls.lines().collect(); + assert_eq!(lines[0], "mcp remove --scope user wanderbooks-beta"); + let add = lines[1]; + assert!( + add.starts_with("mcp add --scope user wanderbooks-beta -- "), + "{add}" + ); + assert!(add.contains(" mcp --book beta --root "), "{add}"); + assert!(!said.contains("wb_"), "a token's text was shown"); + // The command it was given reaches the books (with the token kept). + let given: Vec<&str> = add.split(" -- ").nth(1).unwrap().split(' ').collect(); + let mut c = Command::new(given[0]); + c.args(&given[1..]) + .env_clear() + .env("PATH", "/usr/bin:/bin") + .env("HOME", home.dir.path()); + let mut agent = Agent::from(c); + agent.initialize(); + let start = agent.call("books.start", json!({})).unwrap(); + assert!(start["head"]["seq"].as_u64().is_some(), "{start}"); + agent.end(); + // Again: the token kept is used, not made anew. + let out = connect("claude", &bin); + assert!(out.status.success(), "{out:?}"); + assert!( + !String::from_utf8_lossy(&out.stdout).contains("Made the books"), + "{out:?}" + ); + // Codex: its own list, no scope. + let (bin, log) = fake_agent(&home, "codex"); + let out = connect("codex", &bin); + assert!(out.status.success(), "{out:?}"); + let calls = std::fs::read_to_string(&log).unwrap(); + assert!( + calls + .lines() + .nth(1) + .unwrap() + .starts_with("mcp add wanderbooks-beta -- "), + "{calls}" + ); + // An agent that isn't installed: said. (Never where a real one could + // be found and run: skipped on a machine with one installed system-wide.) + if ["/usr/local/bin/claude", "/opt/homebrew/bin/claude"] + .iter() + .any(|p| Path::new(p).exists()) + { + return; + } + let empty = home.dir.path().join("empty-bin"); + std::fs::create_dir_all(&empty).unwrap(); + let out = home + .cmd("connect") + .args(["claude", "--book", "beta"]) + .env("PATH", empty.display().to_string()) + .stdin(Stdio::null()) + .output() + .unwrap(); + assert_eq!(out.status.code(), Some(1)); + assert!( + String::from_utf8_lossy(&out.stderr).contains("isn't installed"), + "{out:?}" + ); +} + +#[test] +fn books_are_backed_up_now_from_the_command_line() { + let home = Home::new("back-up"); + home.create("acme"); + // None set up: said, and failing. + let out = home.books(&["back-up", "acme"]); + assert_eq!(out.status.code(), Some(1), "{out:?}"); + assert!( + String::from_utf8_lossy(&out.stderr).contains("No backups are set up"), + "{out:?}" + ); + let disk = home.dir.path().join("disk"); + std::fs::create_dir(&disk).unwrap(); + let mut a = Agent::start(&home, "acme"); + a.initialize(); + a.call( + "books.set_setting", + json!({"changes": [{"key": "backup.folder", "value": disk.display().to_string()}], + "decided_by": "Test Owner"}), + ) + .unwrap(); + let out = home.books(&["back-up", "acme"]); + assert!(out.status.success(), "{out:?}"); + assert!( + String::from_utf8_lossy(&out.stdout).contains("Backed up to the backup folder"), + "{out:?}" + ); + assert!(disk.join("acme.git").join("HEAD").is_file()); + // A folder gone since: said, and --new-disk begins it again. + std::fs::rename(disk.join("acme.git"), home.dir.path().join("away.git")).unwrap(); + let out = home.books(&["back-up", "acme"]); + assert_eq!(out.status.code(), Some(1), "{out:?}"); + assert!( + String::from_utf8_lossy(&out.stderr).contains("holds no backup"), + "{out:?}" + ); + let out = home.books(&["back-up", "acme", "--new-disk"]); + assert!(out.status.success(), "{out:?}"); + assert!(disk.join("acme.git").join("HEAD").is_file()); + a.end(); +} + +/// The viewer's owner (ledger-cli's `ViewerOwner`, over these ledger-host +/// functions): its hold is a lease of kind app; new books are made by the +/// server; "Close the books" ends every lease, the viewer's hold with it. +/// (A `serve` holds the books first: run in this test's process, a hold +/// that had to start the host would start this test's own program.) +#[test] +fn the_viewers_hold_new_books_and_close_the_books() { + use ledger_host::commands::{Common, close_books, hold_for_viewer, new_books}; + let home = Home::new("viewer-owner"); + home.create("acme"); + let serving = serve(&home); + let common = Common { + root: Some(home.root()), + knobs: ledger_host::knobs::Knobs::parse(KNOBS).unwrap(), + }; + let hold = hold_for_viewer(&common, Some("acme".into())); + assert!( + wait_for(20, || home + .lease_line("{\"proto\":1,\"leases\":null}\n") + .contains("\"app\"")), + "the viewer's lease never showed" + ); + assert_eq!( + new_books(&common, "beta", "USD").unwrap(), + "Made new books \"beta\" (USD)." + ); + assert!( + new_books(&common, "beta", "USD") + .unwrap_err() + .contains("already books called") + ); + let out = home.books(&["list"]); + assert!( + String::from_utf8_lossy(&out.stdout).contains("beta open"), + "{out:?}" + ); + let said = close_books(&common).unwrap(); + assert!(said.starts_with("The books are closing"), "{said}"); + // The viewer's hold lets go, for good (closed by the owner). + let (tx, rx) = mpsc::channel(); + std::thread::spawn(move || { + let _ = tx.send(hold.join().is_ok()); + }); + assert_eq!(rx.recv_timeout(Duration::from_secs(30)), Ok(true)); + serving.expect("closed", 30); + assert!(home.wait_closed(30)); + // Closed: said so. + assert_eq!(close_books(&common).unwrap(), "The books are closed."); +} + +#[test] +fn connect_makes_anew_a_token_the_keyring_lost_and_says_when_an_agent_refuses() { + use std::os::unix::fs::PermissionsExt; + let home = Home::new("connect-lost"); + home.create("acme"); + // The server keeps the books' agent token; this computer's keyring + // lost it. + std::fs::remove_dir_all(home.run_dir().join("test-keyring")).unwrap(); + let (bin, _log) = fake_agent(&home, "claude"); + let connect = |path: &Path| { + home.cmd("connect") + .args(["claude", "--book", "acme"]) + .env("PATH", format!("{}:/usr/bin:/bin", path.display())) + .stdin(Stdio::null()) + .output() + .unwrap() + }; + let out = connect(&bin); + assert!(out.status.success(), "{out:?}"); + assert!( + String::from_utf8_lossy(&out.stdout).contains("Made the books acme' agent token"), + "{out:?}" + ); + // The agent's own command refuses: said, with what it said. + let refusing = home.dir.path().join("refusing-bin"); + std::fs::create_dir_all(&refusing).unwrap(); + let script = refusing.join("claude"); + std::fs::write( + &script, + "#!/bin/sh\n[ \"$2\" = add ] && { echo 'no room at the inn' >&2; exit 1; }\nexit 0\n", + ) + .unwrap(); + std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap(); + let out = connect(&refusing); + assert_eq!(out.status.code(), Some(1), "{out:?}"); + let err = String::from_utf8_lossy(&out.stderr); + assert!( + err.contains("Claude Code didn't add the books: no room at the inn"), + "{err}" + ); +} diff --git a/crates/ledger-config/src/lib.rs b/crates/ledger-config/src/lib.rs index 7f543d4..a08109c 100644 --- a/crates/ledger-config/src/lib.rs +++ b/crates/ledger-config/src/lib.rs @@ -1,7 +1,8 @@ //! Company settings from the books dir's `wanderbooks.toml`. //! -//! `wanderbooks init` writes that file next to `books-data/`; onboarding -//! fills in `[company]`. The server reads the company name (report headers), +//! An older books dir keeps that file next to `books-data/` until +//! `books.import_settings` moves it into the journal (books of the data +//! root never read one). The server reads the company name (report headers), //! the fiscal year start (fiscal-year-to-date reports), the members of a //! multi-member LLC (`[[company.members]]`: name, share, capital account — //! for the statement of members' equity), `[books] accounting_basis` diff --git a/crates/ledger-gui/src/app.rs b/crates/ledger-gui/src/app.rs index 78abee0..7186227 100644 --- a/crates/ledger-gui/src/app.rs +++ b/crates/ledger-gui/src/app.rs @@ -147,6 +147,8 @@ pub struct App { live_label: bool, /// The export menu (menu.rs). menu: crate::menu::Menu, + /// The owner's Books menu (owner.rs). + owner_menu: crate::owner::OwnerMenu, } impl App { @@ -191,6 +193,7 @@ impl App { updated_at: -1e9, live_label: false, menu: Default::default(), + owner_menu: Default::default(), } } @@ -644,6 +647,7 @@ impl App { if let Some(b) = &books { ui.add_space(2.0); self.menu.ui(ui, b, false); + self.owner_menu.ui(ui, b, false); } ui.add_space(6.0); if let Some(b) = &books { @@ -742,6 +746,7 @@ impl App { self.scheme_menu(ui, true); if let Some(b) = &books { self.menu.ui(ui, b, true); + self.owner_menu.ui(ui, b, true); } ui.add_space(6.0); if let Some(b) = &books { diff --git a/crates/ledger-gui/src/lib.rs b/crates/ledger-gui/src/lib.rs index 9155235..4cb7bfe 100644 --- a/crates/ledger-gui/src/lib.rs +++ b/crates/ledger-gui/src/lib.rs @@ -13,6 +13,7 @@ pub mod export; pub mod fmt; pub mod menu; pub mod nav; +pub mod owner; pub mod pages; pub mod palette; mod prefs; diff --git a/crates/ledger-gui/src/owner.rs b/crates/ledger-gui/src/owner.rs new file mode 100644 index 0000000..5be774f --- /dev/null +++ b/crates/ledger-gui/src/owner.rs @@ -0,0 +1,436 @@ +//! The viewer's Books menu (plan P2 "Viewer menu", D-19, D-32): the +//! owner's own actions on the books, handed to the books server (back up +//! now, restore, new books, close the books) or to the owner's agent +//! (connect). The viewer itself still never writes the books. +//! +//! ```text +//! GOAL: the owner backs up, restores, starts new books, closes the books +//! and connects an agent from the viewer, without a terminal. +//! SOURCE: plan P2 ("Viewer menu (D-19)": "Back up now", "Restore…", +//! "New books…", "Close the books", "Connect Claude Code" and +//! "Connect Codex"; "Nothing in the viewer writes a book"); D-32 +//! (no confinement: the viewer asks the server itself, as the +//! command line does). +//! INVARIANTS: +//! 1. Every action goes through the [`Owner`] the program gives the +//! viewer (the books server, or the agent's own command): nothing +//! here writes a file of the books. +//! 2. Each runs off the UI thread; what came of it is shown, in words. +//! 3. A restore is shown before it is done (the backup proven, what it +//! would do), and done only for that backup, at that head. +//! 4. Without an owner (the viewer on its own, older books dirs, the +//! demo) the menu isn't there. +//! ``` + +use crate::books::Books; +use egui::{RichText, Ui, vec2}; +use egui_phosphor::regular as icon; +use std::sync::mpsc::{Receiver, channel}; +use std::sync::{Arc, OnceLock}; + +/// The owner's agents the viewer can connect. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Agent { + /// Claude Code. + Claude, + /// Codex. + Codex, +} + +impl Agent { + fn label(self) -> &'static str { + match self { + Agent::Claude => "Claude Code", + Agent::Codex => "Codex", + } + } +} + +/// A restore previewed: what it would do, and what it is of (INVARIANT 3). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RestorePlan { + /// The backup, as the server is told it. + pub source: String, + /// The books' id it comes back as. + pub id: String, + /// Whether it replaces the books of that id (kept in legacy/). + pub replace: bool, + /// The backup's head, proven. + pub head: (u64, String), + /// What it would do, a line each. + pub lines: Vec, +} + +/// The owner's actions, done by the program the viewer runs in (ledger-cli +/// over the books server and the agents' own commands). Each blocks: the +/// menu calls it off the UI thread. +pub trait Owner: Send + Sync { + /// Close the books: everything holding them lets go. + fn close_books(&self) -> Result; + /// New, empty books `id` in `currency`. + fn new_books(&self, id: &str, currency: &str) -> Result; + /// Back the books `book` up now. + fn back_up_now(&self, book: &str) -> Result, String>; + /// Preview restoring the backup at `source` (as `id`, else its own + /// books' id), replacing the books of that id or not. + fn plan_restore( + &self, + source: &str, + id: Option, + replace: bool, + ) -> Result; + /// Restore as `plan` previewed it. + fn restore(&self, plan: &RestorePlan) -> Result, String>; + /// Let `agent` reach the books `book`. + fn connect(&self, agent: Agent, book: &str) -> Result, String>; +} + +static OWNER: OnceLock> = OnceLock::new(); + +/// Give the viewer its owner (once, at start; INVARIANT 4). +pub fn set_owner(owner: Arc) { + let _ = OWNER.set(owner); +} + +fn owner() -> Option> { + OWNER.get().cloned() +} + +/// The id of books of the data root (their folder's name), or None for +/// an older books dir (INVARIANT 4). +pub fn book_id(books: &Books) -> Option { + ledger_store::root::inside_a_root(&books.data_dir) + .then(|| books.data_dir.file_name()?.to_str().map(str::to_string)) + .flatten() +} + +type Outcome = Result, String>; + +/// A dialog open over the viewer. +#[derive(Default)] +enum Dialog { + #[default] + None, + /// "New books…": their id and currency. + NewBooks { id: String, currency: String }, + /// "Restore…": the backup, then its preview. + Restore(Box), + /// "Close the books": asked first. + Close, +} + +/// A restore's check under way, and what it was asked: the backup, the +/// id, whether it replaces. +type Checking = ( + Receiver>, + (String, String, bool), +); + +#[derive(Default)] +struct RestoreForm { + source: String, + id: String, + replace: bool, + /// A check under way, and what it was asked (a result for anything + /// since edited is dropped). + planning: Option, + plan: Option, + problem: Option, +} + +/// The menu's state: a dialog, the job under way, and the last outcome. +#[derive(Default)] +pub struct OwnerMenu { + /// The books last shown (by identity) and their id: found once for + /// each load, not every frame. + book: Option<(usize, Option)>, + dialog: Dialog, + job: Option<(String, Receiver)>, + done: Option<(String, Outcome)>, +} + +impl OwnerMenu { + /// The menu button and a note of the last outcome; its dialogs. + pub fn ui(&mut self, ui: &mut Ui, books: &Arc, compact: bool) { + let Some(owner) = owner() else { + return; + }; + let key = Arc::as_ptr(books) as usize; + if self.book.as_ref().is_none_or(|(k, _)| *k != key) { + self.book = Some((key, book_id(books))); + } + let Some(book) = self.book.as_ref().and_then(|(_, id)| id.clone()) else { + return; + }; + let p = crate::theme::pal(ui.ctx()); + if let Some((what, rx)) = &self.job + && let Ok(done) = rx.try_recv() + { + self.done = Some((what.clone(), done)); + self.job = None; + } + let text = if compact { + RichText::new(icon::BOOKS).size(16.0).color(p.muted) + } else { + RichText::new(format!("{} Books", icon::BOOKS)) + .font(crate::theme::regular(12.5)) + .color(p.muted) + }; + let mut button = egui::Button::new(text).frame(false); + if compact { + button = button.min_size(vec2(32.0, 32.0)); + } + let busy = self.job.is_some(); + let mut start: Option<(String, Box Outcome + Send>)> = None; + let response = ui + .add_enabled_ui(!busy, |ui| { + egui::containers::menu::MenuButton::from_button(button).ui(ui, |ui| { + ui.set_min_width(240.0); + if ui + .button(format!("{} Back up now", icon::CLOUD_ARROW_UP)) + .on_hover_text("Back these books up now, to every backup their settings name") + .clicked() + { + let (o, b) = (owner.clone(), book.clone()); + start = Some(("Back up now".into(), Box::new(move || o.back_up_now(&b)))); + } + if ui + .button(format!("{} Restore…", icon::CLOCK_COUNTER_CLOCKWISE)) + .on_hover_text("Bring back books from a backup, proven whole first") + .clicked() + { + self.dialog = Dialog::Restore(Box::default()); + } + if ui + .button(format!("{} New books…", icon::BOOK_OPEN)) + .on_hover_text("Start new, empty books beside these") + .clicked() + { + self.dialog = Dialog::NewBooks { + id: String::new(), + currency: "USD".into(), + }; + } + ui.separator(); + for agent in [Agent::Claude, Agent::Codex] { + if ui + .button(format!("{} Connect {}", icon::PLUGS_CONNECTED, agent.label())) + .on_hover_text(format!( + "Let {} reach these books (it keeps their token in this computer's keyring)", + agent.label() + )) + .clicked() + { + let (o, b) = (owner.clone(), book.clone()); + start = Some(( + format!("Connect {}", agent.label()), + Box::new(move || o.connect(agent, &b)), + )); + } + } + ui.separator(); + if ui + .button(format!("{} Close the books", icon::POWER)) + .on_hover_text("Close the books now: agents' sessions and every other holder let go") + .clicked() + { + self.dialog = Dialog::Close; + } + }) + }) + .inner; + if let Some((what, work)) = start { + self.start(ui.ctx(), what, work); + } + self.dialogs(ui.ctx(), &owner); + let said = if self.job.is_some() { + Some(("Working…".to_string(), p.muted)) + } else { + self.done.as_ref().map(|(what, done)| match done { + Ok(lines) => (format!("{what}: {}", lines.join(" ")), p.muted), + Err(why) => (format!("{what}: {why}"), p.warning), + }) + }; + match said { + Some((text, _)) if compact => { + response.0.on_hover_text(text); + } + Some((text, color)) => { + ui.add(egui::Label::new(RichText::new(text).size(12.0).color(color)).wrap()); + } + None => {} + } + } + + fn start( + &mut self, + ctx: &egui::Context, + what: String, + work: Box Outcome + Send>, + ) { + let (tx, rx) = channel(); + let ctx = ctx.clone(); + std::thread::spawn(move || { + let _ = tx.send(work()); + ctx.request_repaint(); + }); + self.job = Some((what, rx)); + self.done = None; + } + + /// The open dialog, if any. + fn dialogs(&mut self, ctx: &egui::Context, owner: &Arc) { + let mut close = false; + let mut run: Option<(String, Box Outcome + Send>)> = None; + match &mut self.dialog { + Dialog::None => {} + Dialog::Close => { + egui::Modal::new(egui::Id::new("owner-close")).show(ctx, |ui| { + ui.heading("Close the books?"); + ui.label("Agents' sessions and everything else holding them let go. They open again when something holds them."); + ui.horizontal(|ui| { + if ui.button("Close the books").clicked() { + let o = owner.clone(); + run = Some(("Close the books".into(), Box::new(move || o.close_books().map(|l| vec![l])))); + close = true; + } + if ui.button("Cancel").clicked() { + close = true; + } + }); + }); + } + Dialog::NewBooks { id, currency } => { + egui::Modal::new(egui::Id::new("owner-new-books")).show(ctx, |ui| { + ui.heading("New books"); + ui.label("Their id: lowercase letters, digits and hyphens."); + ui.text_edit_singleline(id); + ui.label("Their base currency (fixed for good):"); + ui.text_edit_singleline(currency); + ui.horizontal(|ui| { + let ready = !id.trim().is_empty() && !currency.trim().is_empty(); + if ui + .add_enabled(ready, egui::Button::new("Make them")) + .clicked() + { + let (o, i, c) = (owner.clone(), id.clone(), currency.clone()); + run = Some(( + "New books".into(), + Box::new(move || o.new_books(&i, &c).map(|l| vec![l])), + )); + close = true; + } + if ui.button("Cancel").clicked() { + close = true; + } + }); + }); + } + Dialog::Restore(form) => { + if let Some((rx, asked)) = &form.planning + && let Ok(planned) = rx.try_recv() + { + let current = (form.source.clone(), form.id.clone(), form.replace); + let still = *asked == current; + form.planning = None; + match planned { + // What was checked is still what is asked. + Ok(plan) if still => { + form.id = plan.id.clone(); + form.plan = Some(plan); + form.problem = None; + } + Err(why) if still => form.problem = Some(why), + _ => {} + } + } + egui::Modal::new(egui::Id::new("owner-restore")).show(ctx, |ui| { + ui.set_max_width(520.0); + ui.heading("Restore books"); + ui.label("The backup: its repository's address, or its folder (the books' .git folder in your backup folder)."); + // Not changed while it is being checked. + let open = form.planning.is_none(); + let edited = ui + .add_enabled( + open, + egui::TextEdit::singleline(&mut form.source) + .desired_width(f32::INFINITY) + .hint_text("/media/backup/acme.git, or git@host:you/books.git"), + ) + .changed(); + ui.horizontal(|ui| { + ui.label("As the books"); + let id_edited = ui + .add_enabled( + open, + egui::TextEdit::singleline(&mut form.id).hint_text("its own books' id"), + ) + .changed(); + let replace_edited = ui + .add_enabled( + open, + egui::Checkbox::new(&mut form.replace, "replacing them (kept in the legacy folder)"), + ) + .changed(); + if id_edited || replace_edited { + form.plan = None; + } + }); + if edited { + form.plan = None; + form.problem = None; + } + if let Some(why) = &form.problem { + ui.colored_label(crate::theme::pal(ui.ctx()).warning, why); + } + if let Some(plan) = &form.plan { + ui.separator(); + for line in &plan.lines { + ui.label(line); + } + } + ui.horizontal(|ui| { + let planning = form.planning.is_some(); + match &form.plan { + None => { + let ready = !planning && !form.source.trim().is_empty(); + if ui.add_enabled(ready, egui::Button::new("Check it")).clicked() { + let (tx, rx) = channel(); + let (o, s, r) = (owner.clone(), form.source.clone(), form.replace); + let id = Some(form.id.trim().to_string()).filter(|i| !i.is_empty()); + let ctx = ui.ctx().clone(); + std::thread::spawn(move || { + let _ = tx.send(o.plan_restore(&s, id, r)); + ctx.request_repaint(); + }); + let asked = (form.source.clone(), form.id.clone(), form.replace); + form.planning = Some((rx, asked)); + form.problem = None; + } + if planning { + ui.label("Checking the backup…"); + } + } + Some(plan) => { + if ui.button("Restore it").clicked() { + let (o, plan) = (owner.clone(), plan.clone()); + run = Some(("Restore".into(), Box::new(move || o.restore(&plan)))); + close = true; + } + } + } + if ui.button("Cancel").clicked() { + close = true; + } + }); + }); + } + } + if close { + self.dialog = Dialog::None; + } + if let Some((what, work)) = run { + self.start(ctx, what, work); + } + } +} diff --git a/crates/ledger-gui/tests/owner_menu.rs b/crates/ledger-gui/tests/owner_menu.rs new file mode 100644 index 0000000..50fc5be --- /dev/null +++ b/crates/ledger-gui/tests/owner_menu.rs @@ -0,0 +1,407 @@ +//! The viewer's Books menu (owner.rs, plan P2 "Viewer menu", D-32): each +//! item hands its work to the owner the program gives the viewer, off the +//! UI thread, and says what came of it; a restore is previewed first; the +//! viewer itself writes nothing of the books; without an owner, or for an +//! older books dir, the menu isn't there. + +use chrono::NaiveDate; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use ledger_core::Currency; +use ledger_gui::App; +use ledger_gui::books::Books; +use ledger_gui::data::{self, Source}; +use ledger_gui::nav::Route; +use ledger_gui::owner::{Agent, Owner, RestorePlan}; +use ledger_store::Store; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex, OnceLock}; + +/// An owner that does nothing but say what it was asked. +#[derive(Default)] +struct Fake { + asked: Mutex>, +} + +impl Fake { + fn said(&self, what: String) { + self.asked + .lock() + .unwrap_or_else(|e| e.into_inner()) + .push(what); + } +} + +impl Owner for Fake { + fn close_books(&self) -> Result { + self.said("close".into()); + Ok("The books are closing.".into()) + } + fn new_books(&self, id: &str, currency: &str) -> Result { + self.said(format!("new {id} {currency}")); + Ok(format!("Made new books \"{id}\" ({currency}).")) + } + fn back_up_now(&self, book: &str) -> Result, String> { + // Slow enough to be seen at work. + std::thread::sleep(std::time::Duration::from_millis(300)); + self.said(format!("back up {book}")); + Err("Not backed up to the backup folder: the backup folder isn't there (is its disk connected?)".into()) + } + fn plan_restore( + &self, + source: &str, + id: Option, + replace: bool, + ) -> Result { + std::thread::sleep(std::time::Duration::from_millis(300)); + self.said(format!("plan {source} {id:?} {replace}")); + Ok(RestorePlan { + source: source.to_string(), + id: id.unwrap_or_else(|| "acme".into()), + replace, + head: (7, "ab".repeat(32)), + lines: vec!["The backup at the disk is whole: through event 7.".into()], + }) + } + fn restore(&self, plan: &RestorePlan) -> Result, String> { + self.said(format!( + "restore {} {} {}", + plan.source, plan.id, plan.head.0 + )); + Ok(vec![format!("Restored as \"{}\".", plan.id)]) + } + fn connect(&self, agent: Agent, book: &str) -> Result, String> { + self.said(format!("connect {agent:?} {book}")); + Ok(vec!["Claude Code reaches the books.".into()]) + } +} + +/// One test at a time: they share the one owner and its record of calls. +fn alone() -> std::sync::MutexGuard<'static, ()> { + static ONE: Mutex<()> = Mutex::new(()); + ONE.lock().unwrap_or_else(|e| e.into_inner()) +} + +/// The one owner of this test binary (the viewer takes one, once). +fn fake() -> Arc { + static FAKE: OnceLock> = OnceLock::new(); + FAKE.get_or_init(|| { + let f = Arc::new(Fake::default()); + ledger_gui::owner::set_owner(f.clone()); + f + }) + .clone() +} + +fn today() -> NaiveDate { + NaiveDate::from_ymd_opt(2026, 10, 15).unwrap() +} + +/// Books in a data root (`root`: a folder holding the root's marker), or +/// an older books dir's. +fn books(root: bool) -> (tempfile::TempDir, PathBuf, Books) { + let dir = tempfile::Builder::new() + .prefix("wb-gui-owner-") + .tempdir() + .unwrap(); + let book = if root { + std::fs::write(dir.path().join(ledger_store::root::MARKER_FILE), b"").unwrap(); + dir.path().join("books").join("acme") + } else { + dir.path().join("books-data") + }; + std::fs::create_dir_all(&book).unwrap(); + drop(Store::create(&book, Currency::USD).unwrap()); + let b = data::load_once(&Source { + data_dir: book.clone(), + today: Some(today()), + }) + .unwrap(); + (dir, book, b) +} + +/// Every file under `dir`, with its bytes. +fn files(dir: &Path) -> Vec<(PathBuf, Vec)> { + let mut out = Vec::new(); + for e in std::fs::read_dir(dir).unwrap() { + let p = e.unwrap().path(); + if p.is_dir() { + out.extend(files(&p)); + } else { + out.push((p.clone(), std::fs::read(&p).unwrap())); + } + } + out.sort(); + out +} + +fn harness(b: Books) -> Harness<'static, App> { + let mut h = Harness::builder() + .with_size(egui::vec2(1200.0, 900.0)) + .build_eframe(|cc| App::with_books(&cc.egui_ctx, b, false, Route::Overview)); + h.run_steps(3); + h +} + +/// Run until the owner was asked `n` things, and the outcome shows. +fn settle(h: &mut Harness<'static, App>, f: &Fake, n: usize) { + for _ in 0..200 { + h.run_steps(2); + if f.asked.lock().unwrap().len() >= n { + h.run_steps(4); + return; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + panic!("the owner wasn't asked: {:?}", f.asked.lock().unwrap()); +} + +fn open_menu(h: &mut Harness<'static, App>) { + h.get_by_label_contains(" Books").click(); + h.run_steps(3); +} + +#[test] +fn the_books_menu_hands_each_action_to_the_owner_and_writes_nothing() { + let _alone = alone(); + let f = fake(); + let (_dir, book, b) = books(true); + let before = files(&book); + let mut h = harness(b); + let start = f.asked.lock().unwrap().len(); + + // Back up now: done by the owner; its trouble said, in its words. + open_menu(&mut h); + h.get_by_label_contains("Back up now").click(); + settle(&mut h, &f, start + 1); + assert_eq!(f.asked.lock().unwrap()[start], "back up acme"); + assert!(h.query_by_label_contains("is its disk connected").is_some()); + + // Connect Claude Code. + open_menu(&mut h); + h.get_by_label_contains("Connect Claude Code").click(); + settle(&mut h, &f, start + 2); + assert_eq!(f.asked.lock().unwrap()[start + 1], "connect Claude acme"); + + // New books: asked for, then made. + let page_fields = h + .query_all_by_role(egui::accesskit::Role::TextInput) + .count(); + open_menu(&mut h); + h.get_by_label_contains("New books").click(); + h.run_steps(3); + h.get_all_by_role(egui::accesskit::Role::TextInput) + .nth(page_fields) + .unwrap() + .focus(); + h.run_steps(2); + h.get_all_by_role(egui::accesskit::Role::TextInput) + .nth(page_fields) + .unwrap() + .type_text("beta"); + h.run_steps(2); + h.get_by_label("Make them").click(); + settle(&mut h, &f, start + 3); + assert_eq!(f.asked.lock().unwrap()[start + 2], "new beta USD"); + + // Restore: previewed (the backup checked), then done for that plan. + open_menu(&mut h); + h.get_by_label_contains("Restore…").click(); + h.run_steps(3); + h.get_all_by_role(egui::accesskit::Role::TextInput) + .nth(page_fields) + .unwrap() + .focus(); + h.run_steps(2); + h.get_all_by_role(egui::accesskit::Role::TextInput) + .nth(page_fields) + .unwrap() + .type_text("/media/disk/acme.git"); + h.run_steps(2); + assert!( + h.query_by_label("Restore it").is_none(), + "not before it is checked" + ); + h.get_by_label("Check it").click(); + settle(&mut h, &f, start + 4); + assert_eq!( + f.asked.lock().unwrap()[start + 3], + "plan /media/disk/acme.git None false" + ); + assert!( + h.query_by_label_contains("is whole: through event 7") + .is_some() + ); + h.get_by_label("Restore it").click(); + settle(&mut h, &f, start + 5); + assert_eq!( + f.asked.lock().unwrap()[start + 4], + "restore /media/disk/acme.git acme 7" + ); + + // Close the books: asked first; cancelled, nothing; then done. + open_menu(&mut h); + h.get_by_label_contains("Close the books").click(); + h.run_steps(3); + h.get_by_label("Cancel").click(); + h.run_steps(3); + assert_eq!(f.asked.lock().unwrap().len(), start + 5); + open_menu(&mut h); + h.get_by_label_contains("Close the books").click(); + h.run_steps(3); + h.get_by_label("Close the books").click(); + settle(&mut h, &f, start + 6); + assert_eq!(f.asked.lock().unwrap()[start + 5], "close"); + + // The viewer wrote nothing of the books. + drop(h); + let after: Vec<_> = files(&book) + .into_iter() + .filter(|(p, _)| !p.starts_with(book.join("ops"))) + .collect(); + let before: Vec<_> = before + .into_iter() + .filter(|(p, _)| !p.starts_with(book.join("ops"))) + .collect(); + assert_eq!(after, before); +} + +/// Whether the widget is disabled. +fn disabled(node: &egui_kittest::Node<'_>) -> bool { + use egui_kittest::kittest::NodeT; + node.accesskit_node().is_disabled() +} + +#[test] +fn nothing_is_changed_while_it_is_at_work() { + let _alone = alone(); + let f = fake(); + let (_dir, _book, b) = books(true); + let mut h = harness(b); + // A backup under way: the menu waits for it. + let n = f.asked.lock().unwrap().len(); + open_menu(&mut h); + h.get_by_label_contains("Back up now").click(); + h.run_steps(2); + assert!( + disabled(&h.get_by_label_contains(" Books")), + "the menu works on while busy" + ); + settle(&mut h, &f, n + 1); + for _ in 0..50 { + h.run_steps(2); + if !disabled(&h.get_by_label_contains(" Books")) { + break; + } + std::thread::sleep(std::time::Duration::from_millis(20)); + } + assert!(!disabled(&h.get_by_label_contains(" Books"))); + // A restore being checked: what it asks can't change under it. + let page_fields = h + .query_all_by_role(egui::accesskit::Role::TextInput) + .count(); + open_menu(&mut h); + h.get_by_label_contains("Restore…").click(); + h.run_steps(3); + h.get_all_by_role(egui::accesskit::Role::TextInput) + .nth(page_fields) + .unwrap() + .focus(); + h.run_steps(2); + h.get_all_by_role(egui::accesskit::Role::TextInput) + .nth(page_fields) + .unwrap() + .type_text("/media/disk/acme.git"); + h.run_steps(2); + h.get_by_label("Check it").click(); + h.run_steps(2); + for field in h + .get_all_by_role(egui::accesskit::Role::TextInput) + .skip(page_fields) + { + assert!( + disabled(&field), + "a field could change while it was checked" + ); + } + let n = f.asked.lock().unwrap().len(); + settle(&mut h, &f, n + 1); + assert!(h.query_by_label("Restore it").is_some()); + // Edited after the check: checked again before it can be restored. + h.get_all_by_role(egui::accesskit::Role::TextInput) + .nth(page_fields) + .unwrap() + .focus(); + h.run_steps(2); + h.get_all_by_role(egui::accesskit::Role::TextInput) + .nth(page_fields) + .unwrap() + .type_text("x"); + h.run_steps(3); + assert!(h.query_by_label("Restore it").is_none()); + assert!(h.query_by_label("Check it").is_some()); +} + +#[test] +fn an_older_books_dir_has_no_books_menu() { + let _alone = alone(); + let _ = fake(); + let (_dir, _book, b) = books(false); + let h = harness(b); + assert!(h.query_by_label_contains(" Books").is_none()); +} + +/// Screenshots of the menu and its dialogs, for eyes: set `SHOT_DIR`. +#[test] +fn screenshots() { + let Ok(out) = std::env::var("SHOT_DIR") else { + return; + }; + let _alone = alone(); + let out = PathBuf::from(out); + std::fs::create_dir_all(&out).unwrap(); + let f = fake(); + let (_dir, _book, b) = books(true); + let mut h = harness(b); + let shot = |h: &mut Harness<'static, App>, name: &str| { + h.run_steps(3); + h.render() + .unwrap() + .save(out.join(format!("{name}.png"))) + .unwrap(); + }; + open_menu(&mut h); + shot(&mut h, "menu"); + h.get_by_label_contains("Back up now").click(); + let n = f.asked.lock().unwrap().len(); + settle(&mut h, &f, n + 1); + shot(&mut h, "backed-up"); + let page_fields = h + .query_all_by_role(egui::accesskit::Role::TextInput) + .count(); + open_menu(&mut h); + h.get_by_label_contains("New books").click(); + shot(&mut h, "new-books"); + h.get_by_label("Cancel").click(); + open_menu(&mut h); + h.get_by_label_contains("Restore…").click(); + h.run_steps(3); + h.get_all_by_role(egui::accesskit::Role::TextInput) + .nth(page_fields) + .unwrap() + .focus(); + h.run_steps(2); + h.get_all_by_role(egui::accesskit::Role::TextInput) + .nth(page_fields) + .unwrap() + .type_text("/media/disk/acme.git"); + shot(&mut h, "restore-ask"); + h.get_by_label("Check it").click(); + let n = f.asked.lock().unwrap().len(); + settle(&mut h, &f, n + 1); + shot(&mut h, "restore-plan"); + h.get_by_label("Cancel").click(); + open_menu(&mut h); + h.get_by_label_contains("Close the books").click(); + shot(&mut h, "close"); +} diff --git a/crates/ledger-host/src/bridge.rs b/crates/ledger-host/src/bridge.rs index 28d9b58..304f083 100644 --- a/crates/ledger-host/src/bridge.rs +++ b/crates/ledger-host/src/bridge.rs @@ -50,7 +50,7 @@ use crate::knobs::{FLAG, Knobs}; use crate::lease::{HostMsg, Kind, Reason}; -use crate::opener::{Opener, Starter}; +use crate::opener::Opener; use serde_json::{Value, json}; use std::path::PathBuf; use std::sync::{Arc, Mutex}; @@ -1241,15 +1241,7 @@ pub fn run( let book = ledger_store::root::BookId::parse(&book) .map_err(|e| e.to_string())? .to_string(); - // The helper first, before any thread (helper.rs INVARIANT 4). - let helper = Arc::new(crate::helper::Client::spawn(root.as_deref(), &knobs)?); - let opener = Opener::new( - root.as_deref(), - knobs, - Starter::Helper(helper.clone()), - Kind::Bridge, - Some(book.clone()), - )?; + let opener = Opener::new(root.as_deref(), knobs, Kind::Bridge, Some(book.clone()))?; // Kept for the whole session: the client reaches the socket by it. let socket = ledger_store::root::SocketPath::new(opener.run_dir(), ledger_mcp::local::MCP_SOCKET) @@ -1337,7 +1329,6 @@ pub fn run( Ok::<(), String>(()) }); rt.shutdown_timeout(Duration::from_secs(1)); - drop(helper); result.map_err(Into::into) } diff --git a/crates/ledger-host/src/commands.rs b/crates/ledger-host/src/commands.rs index 084e23c..6daba9e 100644 --- a/crates/ledger-host/src/commands.rs +++ b/crates/ledger-host/src/commands.rs @@ -1,10 +1,14 @@ -//! The owner's commands: `wanderbooks serve`, `close`, `books` and -//! `token` (plan 3.3, 3.9, 5.5). +//! The owner's commands: `wanderbooks serve`, `close`, `books` (list, +//! create, adopt, restore, back-up), `token`, `connect`, `doctor` and +//! `passphrase` (plan 3.3, 3.7, 3.9, 5.5), and the same work for the +//! viewer's Books menu (`new_books`, `back_up_now`, `plan_restore`, +//! `carry_out_restore`, `connect_agent`, `close_books`, `hold_for_viewer`). //! //! ```text -//! GOAL: from a terminal the owner holds the books open, closes them, and -//! makes, lists and adopts books, without ever naming or touching -//! the books' private folder. +//! GOAL: from a terminal, or the viewer's menu, the owner holds the books +//! open, closes them, makes, adopts, backs up and restores books and +//! connects agents, without ever naming or touching the books' +//! private folder. //! SOURCE: plan/served.md 3.9 (a terminal running `wanderbooks serve`, and //! any command that needs the server, holds a lease; "Close the //! books" ends every lease), 3.1 (the CLI talks to admin.sock and @@ -20,12 +24,16 @@ //! 4. A token made is kept in the keyring before anything is said of it, //! and its text is printed only by `token print`; one that can't be //! kept is revoked again. A token revoked is forgotten by the keyring -//! too. +//! too. `connect` makes the books' agent token only when the keyring +//! has none, replacing the server's (no one could be using it: the +//! bridge reads its token from this keyring). +//! 5. The viewer's hold lets go only when the owner closes the books; +//! books that stop, or won't open, are tried again. //! ``` use crate::knobs::{FLAG, Knobs}; use crate::lease::{Ask, HostMsg, Intent, Kind, Reason}; -use crate::opener::{HoldError, Lease, Opener, Starter}; +use crate::opener::{HoldError, Lease, Opener}; use ledger_mcp::admin::{self, Request, Response}; use std::path::{Path, PathBuf}; use std::time::Duration; @@ -38,10 +46,13 @@ const LIST_WAIT: Duration = Duration::from_secs(30); const CLOSE_WAIT: Duration = Duration::from_secs(120); /// Common flags: `--root DIR` and the test knobs. -#[derive(Default)] -struct Common { - root: Option, - knobs: Knobs, +#[derive(Default, Clone)] +pub struct Common { + /// The data root named (`--root`), else the one this account's host + /// finds. + pub root: Option, + /// The test knobs. + pub knobs: Knobs, } impl Common { @@ -119,13 +130,7 @@ fn with_server_in(common: &Common, request: Request) -> Result<(Response, PathBu .build() .map_err(|e| e.to_string())?; let outcome = rt.block_on(async { - let mut opener = Opener::new( - common.root.as_deref(), - common.knobs, - Starter::Direct, - Kind::Cli, - None, - )?; + let mut opener = Opener::new(common.root.as_deref(), common.knobs, Kind::Cli, None)?; let lease = open(&mut opener).await.map_err(|e| match e { HoldError::ClosedByOwner => { "the books were closed just now (Close the books): nothing was done".to_string() @@ -219,7 +224,6 @@ pub fn serve( let mut opener = Opener::new( common.root.as_deref(), common.knobs, - Starter::Direct, Kind::Serve, None, )?; @@ -393,9 +397,13 @@ fn books_usage(prog: &str) { " {prog} adopt DATA-DIR --id ID [--toml FILE] [--config-json FILE] [--vault DIR --vault-key FILE | --no-vault] [--skip-unknown] [--yes]" ); eprintln!(" {prog} restore BACKUP [--id ID] [--replace] [--yes]"); + eprintln!(" {prog} back-up ID [--new-disk]"); eprintln!(" list the books, and whether each is open"); eprintln!(" create start new, empty books (base currency USD unless given)"); eprintln!(" adopt copy existing books in, verified; the original is left as it was"); + eprintln!( + " back-up back the books up now to every backup their settings name (--new-disk: the backup folder's disk is new)" + ); eprintln!( " restore bring back a backup (a git repository's address, or BOOK.git in the backup folder), proven whole first: as new books, or replacing books (kept in legacy/)" ); @@ -418,6 +426,7 @@ pub fn books( let mut skip_unknown = false; let mut yes = false; let mut replace = false; + let mut new_disk = false; let mut args = args.into_iter(); while let Some(arg) = args.next() { if common.take(&arg, &mut args)? { @@ -434,6 +443,7 @@ pub fn books( "--no-vault" => no_vault = true, "--skip-unknown" => skip_unknown = true, "--replace" => replace = true, + "--new-disk" => new_disk = true, "--yes" => yes = true, "--help" | "-h" => { books_usage(prog); @@ -478,6 +488,20 @@ pub fn books( ); } ["restore", source] => return restore(&common, source, id, replace, yes), + ["back-up", book] => { + return match back_up_now(&common, book, new_disk) { + Ok(lines) => { + for line in lines { + println!("{line}"); + } + Ok(()) + } + Err(why) => { + eprintln!("error: {why}"); + Err(exit(1)) + } + }; + } _ => { books_usage(prog); return Err(exit(2)); @@ -521,6 +545,386 @@ pub fn books( } } +/// Back the books `book` up now (the server does it): what came of it, a +/// line each; Err when it couldn't be done, or a target didn't take it. +pub fn back_up_now(common: &Common, book: &str, new_disk: bool) -> Result, String> { + match with_server( + common, + Request::BackUpNow { + id: book.to_string(), + new_disk, + }, + )? { + Response::BackedUp { head_seq, targets } => { + let lines = backed_up_lines(head_seq, &targets); + if targets.is_empty() || targets.iter().any(|t| t.problem.is_some()) { + Err(lines.join("\n")) + } else { + Ok(lines) + } + } + Response::Refused(why) => Err(why), + other => Err(format!("the books server answered oddly: {other:?}")), + } +} + +/// "New books…": new, empty books `id` in `currency` (the server makes +/// them). +pub fn new_books(common: &Common, id: &str, currency: &str) -> Result { + match with_server( + common, + Request::Create { + id: id.trim().to_string(), + currency: currency.trim().to_string(), + }, + )? { + Response::Created { id, problem: None } => { + Ok(format!("Made new books \"{id}\" ({}).", currency.trim())) + } + Response::Created { + id, + problem: Some(why), + } => Err(format!( + "new books \"{id}\" were made but didn't open: {why}. Closing the books and opening them again tries again." + )), + Response::Refused(why) => Err(why), + other => Err(format!("the books server answered oddly: {other:?}")), + } +} + +/// "Close the books": everything holding them lets go (the lease +/// socket's close intent). What came of it. +pub fn close_books(common: &Common) -> Result { + let found = + ledger_store::root::Root::find(common.root.as_deref()).map_err(|e| e.to_string())?; + let run = found.run_dir().map_err(|e| e.to_string())?; + match crate::opener::ask_once_within(&run, Ask::Intent(Intent::Close), HOST_WAIT)? { + None => Ok("The books are closed.".into()), + Some(HostMsg::Done { detail }) => Ok(detail + .map(|d| { + let mut c = d.chars(); + c.next() + .map(|first| format!("{}{}.", first.to_uppercase(), c.as_str())) + .unwrap_or_default() + }) + .filter(|d| !d.is_empty()) + .unwrap_or_else(|| "The books are closing.".into())), + Some(HostMsg::Refused(why)) => Err(why), + Some(HostMsg::Uncertain(why)) => Err(why), + Some(other) => Err(format!("the books' host answered oddly: {other:?}")), + } +} + +/// The longest an agent's own command may take to add the books. +const CONNECT_WAIT: Duration = Duration::from_secs(60); + +/// How long the viewer's hold waits before trying again after books that +/// wouldn't open. +const VIEWER_RETRY: Duration = Duration::from_secs(30); + +/// Hold the books `book` open while the viewer runs (a lease of kind +/// app), on a thread of its own: the window never waits for it. It lets +/// go only when the owner closes the books (and when the viewer ends, +/// with the process); books that stop, or won't open, are tried again. +pub fn hold_for_viewer(common: &Common, book: Option) -> std::thread::JoinHandle<()> { + let common = common.clone(); + std::thread::spawn(move || { + let Ok(rt) = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + else { + return; + }; + rt.block_on(async { + let Ok(mut opener) = Opener::new(common.root.as_deref(), common.knobs, Kind::App, book) + else { + return; + }; + loop { + // The same opener each time round (it knows it held the + // books, so it never reopens books the owner closed); its + // failures counted afresh. + opener.forget_failures(); + loop { + let mut lease = match open(&mut opener).await { + Ok(lease) => lease, + Err(HoldError::ClosedByOwner) => return, + Err(HoldError::Failed(_)) => break, + }; + match lease.next().await { + Some(HostMsg::Ending { + reason: Reason::ClosedByOwner, + .. + }) => return, + Some(HostMsg::Ending { + reason: Reason::Restarting | Reason::Closing, + .. + }) => {} + _ => { + if opener.failed().await.is_err() { + break; + } + } + } + } + tokio::time::sleep(VIEWER_RETRY).await; + } + }); + }) +} + +/// What a backup made now came to, a line each. +pub fn backed_up_lines(head_seq: u64, targets: &[admin::BackedUpTarget]) -> Vec { + if targets.is_empty() { + return vec![ + "No backups are set up: ask your agent to set the private repository and the backup folder.".into(), + ]; + } + targets + .iter() + .map(|t| match (&t.problem, t.through_seq) { + (None, Some(seq)) if seq == head_seq => { + format!("Backed up to {} (through event {seq}).", t.at) + } + (None, Some(seq)) => format!("Backed up to {} through event {seq}.", t.at), + (None, None) => format!("Not yet backed up to {}.", t.at), + (Some(why), _) => format!("Not backed up to {}: {why}", t.at), + }) + .collect() +} + +/// `wanderbooks connect claude|codex --book ID`: an agent of the owner's +/// reaches the books. +pub fn connect( + prog: &str, + args: impl IntoIterator, +) -> Result<(), Box> { + let mut common = Common::default(); + let mut book: Option = None; + let mut words: Vec = Vec::new(); + let mut args = args.into_iter(); + while let Some(arg) = args.next() { + if common.take(&arg, &mut args)? { + continue; + } + match arg.as_str() { + "--book" => book = Some(args.next().ok_or("--book needs an id")?), + "--help" | "-h" => { + eprintln!("usage: {prog} claude|codex --book ID"); + eprintln!( + " Lets Claude Code or Codex reach the books: keeps their token in this computer's keyring and adds the books to the agent's own list." + ); + return Ok(()); + } + s if s.starts_with('-') => { + eprintln!("unknown flag: {s}"); + eprintln!("usage: {prog} claude|codex --book ID"); + return Err(exit(2)); + } + _ => words.push(arg), + } + } + let (Some(agent), Some(book), 1) = (words.first(), book, words.len()) else { + eprintln!("usage: {prog} claude|codex --book ID"); + return Err(exit(2)); + }; + match connect_agent(&common, agent, &book) { + Ok(lines) => { + for line in lines { + println!("{line}"); + } + Ok(()) + } + Err(why) => { + eprintln!("error: {why}"); + Err(exit(1)) + } + } +} + +/// Let the agent `agent` ("claude" or "codex") reach the books `book`: +/// their token kept in the keyring (made if none is), and `wanderbooks +/// mcp --book ` added to the agent's own list as +/// `wanderbooks-`. What was done, a line each. +pub fn connect_agent(common: &Common, agent: &str, book: &str) -> Result, String> { + let (cli, label) = match agent { + "claude" => ("claude", "Claude Code"), + "codex" => ("codex", "Codex"), + other => return Err(format!("{other:?}: connect claude or codex")), + }; + let book = ledger_store::root::BookId::parse(book) + .map_err(|e| e.to_string())? + .to_string(); + // The PATH first; then where the agents' installers put them (a + // viewer started from the desktop has a short PATH). + // ($HOME: a test's own home is never the account's.) + let home = std::env::var_os("HOME") + .map(PathBuf::from) + .filter(|h| h.is_absolute()); + let usual = [ + ".local/bin", + ".npm-global/bin", + ".bun/bin", + ".cargo/bin", + ".volta/bin", + ] + .into_iter() + .filter_map(|d| home.as_ref().map(|h| h.join(d))) + .chain( + ["/usr/local/bin", "/opt/homebrew/bin"] + .into_iter() + .map(PathBuf::from), + ); + let program = std::env::var_os("PATH") + .map(|p| std::env::split_paths(&p).collect::>()) + .unwrap_or_default() + .into_iter() + .chain(usual) + .filter(|d| d.is_absolute()) + .map(|d| d.join(cli)) + .find(|p| { + use std::os::unix::fs::PermissionsExt; + std::fs::metadata(p).is_ok_and(|m| m.is_file() && m.permissions().mode() & 0o111 != 0) + }) + .ok_or_else(|| { + format!("{label} isn't installed here (no `{cli}` on the PATH, nor where its installer puts it)") + })?; + let mut lines = Vec::new(); + // The token: kept in the keyring, or made now. + let found = + ledger_store::root::Root::find(common.root.as_deref()).map_err(|e| e.to_string())?; + let run = found.run_dir().map_err(|e| e.to_string())?; + let account = crate::tokens::Account::new(found.path(), &book, crate::tokens::DEFAULT_NAME); + if crate::tokens::keeper(&run, &common.knobs) + .get(&account)? + .is_none() + { + let scopes = ledger_store::auth::Scope::ALL + .iter() + .map(|s| s.as_str().to_string()) + .collect(); + make_token(common, &book, crate::tokens::DEFAULT_NAME, scopes, true)?; + lines.push(format!( + "Made the books {book}' agent token, kept in this computer's keyring." + )); + } + // The agent's own list: the books' entry made anew. + let exe = crate::start::this_executable()?; + let name = format!("wanderbooks-{book}"); + let mut serve: Vec = vec![ + exe.into_os_string(), + "mcp".into(), + "--book".into(), + book.clone().into(), + ]; + if common.root.is_some() { + serve.push("--root".into()); + serve.push(found.path().as_os_str().to_os_string()); + } + serve.extend(common.knobs.args().into_iter().map(Into::into)); + let scope: &[&str] = if agent == "claude" { + &["--scope", "user"] + } else { + &[] + }; + // Each call within a minute: an agent's command that hangs is said, + // never waited on for good. + let run_cli = |args: Vec| -> Result { + let mut child = std::process::Command::new(&program) + .args(args) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::piped()) + .spawn() + .map_err(|e| format!("{label} couldn't be run: {}", e.kind()))?; + let deadline = std::time::Instant::now() + CONNECT_WAIT; + loop { + if child.try_wait().map_err(|e| e.to_string())?.is_some() { + return child.wait_with_output().map_err(|e| e.to_string()); + } + if std::time::Instant::now() > deadline { + let _ = child.kill(); + let _ = child.wait(); + return Err(format!("{label} didn't answer in time: nothing was added")); + } + std::thread::sleep(Duration::from_millis(50)); + } + }; + let mut remove: Vec = vec!["mcp".into(), "remove".into()]; + remove.extend(scope.iter().map(Into::into)); + remove.push(name.clone().into()); + let _ = run_cli(remove); + let mut add: Vec = vec!["mcp".into(), "add".into()]; + add.extend(scope.iter().map(Into::into)); + add.push(name.clone().into()); + add.push("--".into()); + add.extend(serve); + let out = run_cli(add)?; + if !out.status.success() { + let said: String = String::from_utf8_lossy(&out.stderr) + .trim() + .chars() + .take(300) + .collect(); + return Err(format!("{label} didn't add the books: {said}")); + } + lines.push(format!( + "{label} reaches the books {book} as \"{name}\" (restart {label} to see it)." + )); + Ok(lines) +} + +/// Make the token `name` for `book` (the server does) and keep it in this +/// computer's keyring (INVARIANT 4: kept first, or taken back). +fn make_token( + common: &Common, + book: &str, + name: &str, + scopes: Vec, + replace: bool, +) -> Result { + let request = Request::TokenCreate { + book: book.to_string(), + name: name.to_string(), + scopes, + replace, + }; + let (answer, run_dir) = with_server_in(common, request)?; + let (info, text) = match answer { + Response::TokenCreated { token, text } => (token, zeroize::Zeroizing::new(text.0)), + Response::Refused(why) => return Err(why), + other => return Err(format!("the books server answered oddly: {other:?}")), + }; + let root = run_dir.parent().unwrap_or(&run_dir).to_path_buf(); + let account = crate::tokens::Account::new(&root, book, name); + let kept = crate::tokens::keeper(&run_dir, &common.knobs).set(&account, &text); + drop(text); + if let Err(why) = kept { + let undone = with_server( + common, + Request::TokenRevoke { + book: book.to_string(), + name: name.to_string(), + // This one only: another may have replaced it. + id: Some(info.id.clone()), + }, + ); + let after = match undone { + Ok(Response::TokenRevoked(_)) => "so it was revoked again", + _ => "and revoking it again failed too: `wanderbooks token revoke` ends it", + }; + let replaced = if replace { + " The token it replaced had already ended." + } else { + "" + }; + return Err(format!( + "the token was made, but this computer's keyring couldn't keep it ({why}), {after}.{replaced}" + )); + } + Ok(info) +} + /// The id a backup is restored as when none is given: its repository's /// name (`acme.git` → `acme`). fn restore_id(source: &str) -> Option { @@ -564,6 +968,61 @@ fn restore( eprintln!("error: {why}"); Err(exit(1)) }; + let plan = match plan_restore(common, source, id, replace, true) { + Ok(plan) => plan, + Err(why) => return fail(why), + }; + for line in &plan.lines { + println!("{line}"); + } + match confirmed("Restore it?", yes) { + Ok(true) => {} + Ok(false) => { + println!("Not restored."); + return Ok(()); + } + Err(why) => return fail(format!("not restored: {why}")), + } + match carry_out_restore(common, &plan) { + Ok(lines) => { + for line in lines { + println!("{line}"); + } + Ok(()) + } + Err(why) => fail(why), + } +} + +/// A restore previewed: the backup proven, the id it comes back as, and +/// what it would do, a line each (INVARIANT 3: shown before it is done). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RestorePlan { + /// The backup, as the server is told it (a folder by its full path). + pub source: String, + /// The books' id it comes back as. + pub id: String, + /// Whether it replaces the books of that id (kept in legacy/). + pub replace: bool, + /// The backup's head, proven: the restore is of this backup only. + pub head: (u64, String), + /// The id of the books it holds, if it says. + pub origin: Option, + /// What it would do, a line each. + pub lines: Vec, +} + +/// Preview restoring the backup at `source` (`flags`: name the command +/// line's flags in the advice): the id it comes back as is `id`, else the +/// id of the books the backup holds, else the repository's name; refused +/// over books that exist unless `replace`, and replacing none. +pub fn plan_restore( + common: &Common, + source: &str, + id: Option, + replace: bool, + flags: bool, +) -> Result { // A folder is named to the server by its full path (the server's // working folder isn't this one); anything else is an address. let source = match Path::new(source) { @@ -571,11 +1030,10 @@ fn restore( .unwrap_or_else(|_| p.to_path_buf()) .display() .to_string(), - _ => source.to_string(), + _ => source.trim().to_string(), }; // One preview (under the id given, or any: it only reports), then the - // id: given, or else the id of the books the backup holds, or else the - // repository's name. + // id. let probe = id .clone() .or_else(|| restore_id(&source)) @@ -589,10 +1047,17 @@ fn restore( confirmed: false, expect_head: None, }, - ) { - Ok(Response::RestorePreview(p)) => p, - Ok(Response::Refused(why)) | Err(why) => return fail(format!("{source}: {why}")), - Ok(other) => return fail(format!("the books server answered oddly: {other:?}")), + )? { + Response::RestorePreview(p) => p, + Response::Refused(why) => return Err(format!("{source}: {why}")), + other => return Err(format!("the books server answered oddly: {other:?}")), + }; + let flag = |text: &str| { + if flags { + format!(" ({text})") + } else { + String::new() + } }; let Some(id) = id .or_else(|| { @@ -603,87 +1068,98 @@ fn restore( }) .or_else(|| restore_id(&source)) else { - eprintln!("error: name the restored books with --id ID"); - return Err(exit(2)); + return Err(format!("name the restored books{}", flag("--id ID"))); }; - let exists = match with_server(common, Request::List) { - Ok(Response::Books(books)) => books.iter().any(|b| b.id == id), - Ok(Response::Refused(why)) | Err(why) => return fail(why), - Ok(other) => return fail(format!("the books server answered oddly: {other:?}")), + let exists = match with_server(common, Request::List)? { + Response::Books(books) => books.iter().any(|b| b.id == id), + Response::Refused(why) => return Err(why), + other => return Err(format!("the books server answered oddly: {other:?}")), }; if exists && !replace { - return fail(format!( - "there are already books called \"{id}\": restore them under another id (--id), or replace them (--replace; they are kept in legacy/)" + return Err(format!( + "there are already books called \"{id}\": restore them under another id{}, or replace them{}", + flag("--id"), + flag("--replace; they are kept in legacy/") )); } if !exists && replace { - return fail(format!( + return Err(format!( "there are no books called \"{id}\" to replace: nothing was restored" )); } - let request = |expect_head: Option<(u64, String)>| Request::Restore { - id: id.clone(), - source: source.clone(), - replace, - confirmed: expect_head.is_some(), - expect_head, - }; - println!( + let mut lines = vec![format!( "The backup at {source} is whole: through event {} ({}), {} stored files re-hashed, {} TIN vault records.", preview.head.0, short_hash(&preview.head.1), preview.blobs, preview.vault_files - ); + )]; if let Some(origin) = &preview.origin { - println!("It is a backup of the books \"{origin}\"."); + lines.push(format!("It is a backup of the books \"{origin}\".")); } - if replace { - println!( + lines.push(if replace { + format!( "It replaces the books \"{id}\": they are closed and kept whole in the legacy folder, not deleted." - ); + ) } else { - println!("It comes back as new books \"{id}\", beside any others."); - } + format!("It comes back as new books \"{id}\", beside any others.") + }); match preview.origin.as_deref() { Some(o) if o == id => {} - Some(o) => println!( - "Under a name other than its books' (\"{o}\"), they won't back up to the private repository those books back up to (that would stop theirs): if these are those books, restore them as \"{o}\" (--id {o}); else name a repository of their own." - ), - None => println!( + Some(o) => lines.push(format!( + "Under a name other than its books' (\"{o}\"), they won't back up to the private repository those books back up to (that would stop theirs): if these are those books, restore them as \"{o}\"{}; else name a repository of their own.", + flag(&format!("--id {o}")) + )), + None => lines.push( "It doesn't say whose books it holds: they won't back up to the private repository their settings name until one of their own is named." + .into(), ), } - match confirmed("Restore it?", yes) { - Ok(true) => {} - Ok(false) => { - println!("Not restored."); - return Ok(()); - } - Err(why) => return fail(format!("not restored: {why}")), - } - match with_server(common, request(Some(preview.head.clone()))) { - Ok(Response::Restored(r)) => { - println!( + Ok(RestorePlan { + source, + id, + replace, + head: preview.head, + origin: preview.origin, + lines, + }) +} + +/// Restore as `plan` previewed it (only that backup, at that head): what +/// was done, a line each. +pub fn carry_out_restore(common: &Common, plan: &RestorePlan) -> Result, String> { + let request = Request::Restore { + id: plan.id.clone(), + source: plan.source.clone(), + replace: plan.replace, + confirmed: true, + expect_head: Some(plan.head.clone()), + }; + match with_server(common, request)? { + Response::Restored(r) => { + let mut lines = vec![format!( "Restored as \"{}\": through event {} ({}), {} stored files checked, {} TIN vault records.", r.id, r.head.0, short_hash(&r.head.1), r.blobs_verified, r.vault_files - ); + )]; if let Some(kept) = &r.replaced { - println!("The books it replaced are kept in the legacy folder as {kept}."); - } - if let Some(why) = r.problem { - return fail(format!( - "the restored books didn't open: {why}. Closing the books and opening them again tries again." + lines.push(format!( + "The books it replaced are kept in the legacy folder as {kept}." )); } - Ok(()) + if let Some(why) = r.problem { + return Err(format!( + "{}\nThe restored books didn't open: {why}. Closing the books and opening them again tries again.", + lines.join("\n") + )); + } + Ok(lines) } - Ok(Response::Refused(why)) | Err(why) => fail(format!("{source}: {why}")), - Ok(other) => fail(format!("the books server answered oddly: {other:?}")), + Response::Refused(why) => Err(format!("{}: {why}", plan.source)), + other => Err(format!("the books server answered oddly: {other:?}")), } } @@ -1118,46 +1594,7 @@ pub fn token( match words.as_slice() { ["create"] => { let book = need_book()?; - let request = Request::TokenCreate { - book: book.clone(), - name: name.clone(), - scopes, - replace, - }; - let (answer, run_dir) = with_server_in(&common, request).map_err(fail)?; - let (info, text) = match answer { - Response::TokenCreated { token, text } => (token, zeroize::Zeroizing::new(text.0)), - Response::Refused(why) => return Err(fail(why)), - other => return Err(fail(format!("the books server answered oddly: {other:?}"))), - }; - // INVARIANT 4: kept first, or taken back. - let root = run_dir.parent().unwrap_or(&run_dir).to_path_buf(); - let account = crate::tokens::Account::new(&root, &book, &name); - let kept = crate::tokens::keeper(&run_dir, &common.knobs).set(&account, &text); - drop(text); - if let Err(why) = kept { - let undone = with_server( - &common, - Request::TokenRevoke { - book: book.clone(), - name: name.clone(), - // This one only: another may have replaced it. - id: Some(info.id.clone()), - }, - ); - let after = match undone { - Ok(Response::TokenRevoked(_)) => "so it was revoked again", - _ => "and revoking it again failed too: `wanderbooks token revoke` ends it", - }; - let replaced = if replace { - " The token it replaced had already ended." - } else { - "" - }; - return Err(fail(format!( - "the token was made, but this computer's keyring couldn't keep it ({why}), {after}.{replaced}" - ))); - } + let info = make_token(&common, &book, &name, scopes, replace).map_err(fail)?; println!( "Made the token {:?} for the books {} ({}), kept in this computer's keyring.", info.name, @@ -1222,14 +1659,8 @@ pub fn token( } ["print"] => { let book = need_book()?; - let opener = Opener::new( - common.root.as_deref(), - common.knobs, - Starter::Direct, - Kind::Cli, - None, - ) - .map_err(|e| fail(e.to_string()))?; + let opener = Opener::new(common.root.as_deref(), common.knobs, Kind::Cli, None) + .map_err(|e| fail(e.to_string()))?; let run_dir = opener.run_dir().to_path_buf(); let root = run_dir.parent().unwrap_or(&run_dir).to_path_buf(); let account = crate::tokens::Account::new(&root, &book, &name); @@ -1348,7 +1779,6 @@ fn on_the_socket( let mut opener = Opener::new( common.root.as_deref(), common.knobs, - Starter::Direct, Kind::Cli, None, )?; diff --git a/crates/ledger-host/src/helper.rs b/crates/ledger-host/src/helper.rs deleted file mode 100644 index 5af7ee1..0000000 --- a/crates/ledger-host/src/helper.rs +++ /dev/null @@ -1,337 +0,0 @@ -//! `wanderbooks helper`: the small unconfined process a confined mode -//! keeps beside it (plan 3.9, "Starting the host cleanly"). -//! -//! ```text -//! GOAL: a mode that confines itself (the viewer, the bridge) can still -//! start the host cleanly and hand off the few things a sandbox -//! forbids, through a channel that can ask for nothing else. -//! SOURCE: plan/served.md 3.9 (the helper: spawned over an inherited -//! socket pair before any thread, a fixed message set; it never -//! accepts a path or a command line). -//! INVARIANTS: -//! 1. It talks only over the socket it was handed (descriptor 3), one -//! JSON line each way, and exits when that closes. -//! 2. It accepts exactly start_host, open_with (an attachment id), -//! stage_backup and start_updater, none carrying a path or command; -//! anything else is refused. -//! 3. start_host starts the host cleanly (start.rs). -//! 4. Spawned before the caller starts any thread. -//! 5. A message is exactly its version and one ask, on a line of at -//! most MAX_LINE bytes; anything else ends the helper. -//! ``` - -use crate::knobs::{FLAG, Knobs}; -use serde::{Deserialize, Serialize}; -use std::io::{BufRead, Write}; -use std::os::fd::{AsRawFd, FromRawFd}; -use std::os::unix::net::UnixStream; -use std::os::unix::process::CommandExt; -use std::path::{Path, PathBuf}; - -/// This protocol's version. -pub const PROTO: u32 = 1; -/// The descriptor the helper is handed its socket on. -const FD: i32 = 3; -/// The longest line either side sends. -const MAX_LINE: u64 = 64 * 1024; - -/// A message to the helper (INVARIANT 2). -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct Message { - /// The sender's protocol version. - pub proto: u32, - /// What it asks. - #[serde(flatten)] - pub ask: HelperAsk, -} - -/// What the helper can be asked: exactly these (INVARIANT 2). -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case", deny_unknown_fields)] -pub enum HelperAsk { - /// Start the books' host. - StartHost, - /// Open a document with the system's app for it. - OpenWith { - /// The attachment's id. - attachment: u64, - }, - /// Ask the owner for a backup file and stage it for a restore. - StageBackup, - /// Start the updater. - StartUpdater, -} - -/// The helper's answer. -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case", deny_unknown_fields)] -pub enum Answer { - /// Done. - Done, - /// Refused, with why. - Refused(String), -} - -/// The caller's end: a running helper. -pub struct Client { - stream: std::sync::Mutex, - child: std::sync::Mutex, -} - -impl Client { - /// Spawn a helper for the root (`root`, else this account's), with - /// these knobs. Call it before starting any thread (INVARIANT 4). - pub fn spawn(root: Option<&Path>, knobs: &Knobs) -> Result { - let (ours, theirs) = UnixStream::pair().map_err(|e| e.to_string())?; - let exe = crate::start::this_executable()?; - let mut c = std::process::Command::new(exe); - c.arg("helper"); - if let Some(root) = root { - let found = ledger_store::root::Root::find(Some(root)).map_err(|e| e.to_string())?; - c.arg("--root").arg(found.path()); - } - c.args(knobs.args()) - .stdin(std::process::Stdio::null()) - .stdout(std::process::Stdio::null()); - let fd = theirs.as_raw_fd(); - // SAFETY: dup2 and fcntl are async-signal-safe; they give the child - // its end of the pair as descriptor 3, without close-on-exec (which - // a descriptor already numbered 3 would still have). - unsafe { - c.pre_exec(move || { - let ok = - (fd == FD || libc::dup2(fd, FD) >= 0) && libc::fcntl(FD, libc::F_SETFD, 0) >= 0; - if !ok { - return Err(std::io::Error::last_os_error()); - } - Ok(()) - }); - } - let child = c - .spawn() - .map_err(|e| format!("can't start the helper: {e}"))?; - drop(theirs); - Ok(Self { - stream: std::sync::Mutex::new(ours), - child: std::sync::Mutex::new(child), - }) - } - - /// Ask the helper one thing (blocking). - pub fn ask(&self, ask: HelperAsk) -> Result { - let stream = self.stream.lock().unwrap_or_else(|e| e.into_inner()); - let mut line = - serde_json::to_string(&Message { proto: PROTO, ask }).map_err(|e| e.to_string())?; - line.push('\n'); - (&*stream) - .write_all(line.as_bytes()) - .map_err(|e| format!("the helper is gone: {}", e.kind()))?; - let mut answer = String::new(); - std::io::BufReader::new(std::io::Read::take(&*stream, MAX_LINE)) - .read_line(&mut answer) - .map_err(|e| format!("the helper didn't answer: {}", e.kind()))?; - serde_json::from_str(answer.trim_end()).map_err(|_| "the helper is gone".to_string()) - } - - /// Start the books' host (INVARIANT 3). - pub fn start_host(&self) -> Result<(), String> { - match self.ask(HelperAsk::StartHost)? { - Answer::Done => Ok(()), - Answer::Refused(why) => Err(why), - } - } -} - -impl Drop for Client { - fn drop(&mut self) { - // Closing our end ends the helper (INVARIANT 1); reap it. - let stream = self.stream.lock().unwrap_or_else(|e| e.into_inner()); - let _ = stream.shutdown(std::net::Shutdown::Both); - drop(stream); - let _ = self.child.lock().unwrap_or_else(|e| e.into_inner()).wait(); - } -} - -/// What the helper does with one message. -fn answer(ask: HelperAsk, root: Option<&Path>, knobs: &Knobs) -> Answer { - match ask { - HelperAsk::StartHost => match crate::start::start_host(root, knobs) { - Ok(()) => Answer::Done, - Err(why) => Answer::Refused(why), - }, - HelperAsk::OpenWith { .. } => { - Answer::Refused("opening documents comes with the viewer's menu".into()) - } - HelperAsk::StageBackup => Answer::Refused( - "restore from a terminal for now: `wanderbooks books restore BACKUP`".into(), - ), - HelperAsk::StartUpdater => Answer::Refused("updates come with the installers".into()), - } -} - -/// `wanderbooks helper`: serve the socket on descriptor 3 until it closes -/// (INVARIANT 1). -pub fn run( - prog: &str, - args: impl IntoIterator, -) -> Result<(), Box> { - let mut root: Option = None; - let mut knobs = Knobs::default(); - let mut args = args.into_iter(); - while let Some(arg) = args.next() { - match arg.as_str() { - "--root" => root = Some(PathBuf::from(args.next().ok_or("--root needs a folder")?)), - FLAG => knobs = Knobs::parse(&args.next().ok_or("--test-knobs needs a value")?)?, - "--help" | "-h" => { - eprintln!("usage: {prog} [--root DIR]"); - eprintln!(" Started by a confined part of wanderbooks, over descriptor 3."); - return Ok(()); - } - other => { - eprintln!("unknown argument: {other}"); - return Err(Box::new(crate::Exit(2))); - } - } - } - // SAFETY: descriptor 3 is ours by INVARIANT 1; if it isn't a socket, - // the peer check below refuses it. - let stream = unsafe { UnixStream::from_raw_fd(FD) }; - // Taken: nothing this helper starts inherits it. - // SAFETY: fcntl on our own descriptor. - unsafe { libc::fcntl(FD, libc::F_SETFD, libc::FD_CLOEXEC) }; - if !ledger_mcp::local::peer_is_us(&stream) { - eprintln!("{prog}: descriptor 3 isn't a socket to this account's own process"); - return Err(Box::new(crate::Exit(2))); - } - serve(std::io::BufReader::new(&stream), &stream, |ask| { - answer(ask, root.as_deref(), &knobs) - }); - Ok(()) -} - -/// Answer each message read from `reader` on `writer`, a line each, with -/// `reply` for the asks of this version, until the input ends or a line -/// is too long or cut off (INVARIANTS 1, 5). -fn serve(mut reader: R, mut writer: W, reply: impl Fn(HelperAsk) -> Answer) { - loop { - // INVARIANT 5: never more than a line's worth read. - let mut line = String::new(); - match std::io::Read::take(&mut reader, MAX_LINE + 1).read_line(&mut line) { - Ok(0) | Err(_) => break, - Ok(_) if !line.ends_with('\n') => break, - Ok(_) => {} - } - let parsed = crate::lease::strict_pair(line.trim_end()) - .and_then(|v| serde_json::from_value::(v).map_err(|e| e.to_string())); - let answer = match parsed { - Ok(m) if m.proto == PROTO => reply(m.ask), - Ok(_) => Answer::Refused("the helper is another version: restart".into()), - Err(e) => Answer::Refused(format!("not understood: {e}")), - }; - let mut text = serde_json::to_string(&answer).unwrap_or_default(); - text.push('\n'); - if writer.write_all(text.as_bytes()).is_err() { - break; - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use serde_json::json; - - #[test] - fn exactly_four_asks_and_none_carries_a_path() { - for ok in [ - json!({"proto": 1, "start_host": null}), - json!({"proto": 1, "open_with": {"attachment": 7}}), - json!({"proto": 1, "stage_backup": null}), - json!({"proto": 1, "start_updater": null}), - ] { - assert!( - serde_json::from_value::(ok.clone()).is_ok(), - "{ok}" - ); - } - for bad in [ - json!({"proto": 1, "open_with": {"attachment": 7, "path": "/etc/passwd"}}), - json!({"proto": 1, "open_with": {"path": "/x"}}), - json!({"proto": 1, "run": {"command": "sh"}}), - json!({"proto": 1, "start_host": {"root": "/x"}}), - json!({"proto": 1}), - ] { - assert!( - serde_json::from_value::(bad.clone()).is_err(), - "{bad}" - ); - } - // Two asks in one message: refused, not half-read. - let two = r#"{"proto":1,"start_host":null,"start_updater":null}"#; - assert!(crate::lease::strict_pair(two).is_err()); - } - - /// Serve `input`; the answers, a line each. - fn served(input: &[u8]) -> Vec { - let mut out: Vec = Vec::new(); - serve(input, &mut out, |ask| match ask { - HelperAsk::StartHost => Answer::Done, - _ => Answer::Refused("no".into()), - }); - String::from_utf8(out) - .unwrap() - .lines() - .map(str::to_string) - .collect() - } - - #[test] - fn each_line_is_answered_and_a_bad_one_ends_it() { - let ok = r#"{"proto":1,"start_host":null}"#; - let other = r#"{"proto":2,"start_host":null}"#; - let got = served(format!("{ok}\n{other}\nnonsense\n{ok}\n").as_bytes()); - assert_eq!(got[0], r#""done""#); - assert!(got[1].contains("another version"), "{}", got[1]); - assert!(got[2].contains("not understood"), "{}", got[2]); - assert_eq!(got[3], r#""done""#); - assert_eq!(got.len(), 4); - // A line at the limit is read; one byte more ends the helper, - // and so does one cut off without its newline. - let at = format!("{ok}{}\n", " ".repeat(MAX_LINE as usize - ok.len())); - assert_eq!(served(at.as_bytes()), [r#""done""#]); - let over = format!( - "{ok}{}\n{ok}\n", - " ".repeat(MAX_LINE as usize - ok.len() + 1) - ); - assert!(served(over.as_bytes()).is_empty()); - assert!(served(ok.as_bytes()).is_empty()); - // An answer that can't be written ends it. - struct Broken; - impl Write for Broken { - fn write(&mut self, _: &[u8]) -> std::io::Result { - Err(std::io::ErrorKind::BrokenPipe.into()) - } - fn flush(&mut self) -> std::io::Result<()> { - Ok(()) - } - } - let asked = std::cell::Cell::new(0); - serve(format!("{ok}\n{ok}\n").as_bytes(), Broken, |_| { - asked.set(asked.get() + 1); - Answer::Done - }); - assert_eq!(asked.get(), 1, "nothing more after the first answer failed"); - } - - #[test] - fn what_isnt_built_yet_is_refused() { - let k = Knobs::default(); - for ask in [ - HelperAsk::OpenWith { attachment: 1 }, - HelperAsk::StageBackup, - HelperAsk::StartUpdater, - ] { - assert!(matches!(answer(ask, None, &k), Answer::Refused(_))); - } - } -} diff --git a/crates/ledger-host/src/host.rs b/crates/ledger-host/src/host.rs index d493055..a1b2ee5 100644 --- a/crates/ledger-host/src/host.rs +++ b/crates/ledger-host/src/host.rs @@ -539,16 +539,6 @@ impl Host { Phase::Closing(reason) => HostMsg::Ending { reason, detail: None }, }); } - Event::Intent(Intent::NewBooks { id, currency }, answer) - if self.ready && self.phase == Phase::Open => - { - // The server does it; the loop goes on meanwhile. - let socket = self.run.join(ledger_mcp::local::ADMIN_SOCKET); - tokio::spawn(async move { - let msg = new_books(socket, id, currency).await; - let _ = answer.send(msg); - }); - } Event::Intent(Intent::Close, answer) if self.phase != Phase::Open => { // INVARIANT 11; closing anyway: already so. let _ = answer.send(match self.phase { @@ -701,27 +691,6 @@ impl Host { }, Some(Reason::ClosedByOwner), ), - Intent::NewBooks { .. } if self.phase == Phase::Open => ( - HostMsg::Refused("the books are still opening: try again in a moment".into()), - None, - ), - Intent::NewBooks { .. } => ( - HostMsg::Refused("the books are closing: nothing was done".into()), - None, - ), - Intent::BackUpNow { .. } => ( - HostMsg::Refused( - "the books back themselves up; to back up now, ask your agent (books.back_up_now)" - .into(), - ), - None, - ), - Intent::Restore { .. } => ( - HostMsg::Refused( - "restore from a terminal for now: `wanderbooks books restore BACKUP`".into(), - ), - None, - ), Intent::ConsentFormatUpgrade { .. } => ( HostMsg::Refused("no books are waiting for a format upgrade".into()), None, @@ -768,30 +737,6 @@ impl Host { } } -/// "New books…": the server makes them, through its admin socket. -async fn new_books(socket: PathBuf, id: String, currency: String) -> HostMsg { - use ledger_mcp::admin::{Request, Response}; - let answer = tokio::task::spawn_blocking(move || { - ledger_mcp::admin::call(&socket, Request::Create { id, currency }) - }) - .await; - match answer { - Ok(Ok(Response::Created { id, problem: None })) => HostMsg::Done { - detail: Some(format!("new books {id} made")), - }, - Ok(Ok(Response::Created { - id, - problem: Some(why), - })) => HostMsg::Refused(format!("new books {id} were made but didn't open: {why}")), - Ok(Ok(Response::Refused(why))) => HostMsg::Refused(why), - Ok(Ok(_)) => HostMsg::Refused("the books server answered oddly".into()), - Ok(Err(ledger_mcp::admin::CallError::NotSent(why))) => HostMsg::Refused(why), - // Sent, and no answer: never reported as not done. - Ok(Err(ledger_mcp::admin::CallError::Unknown(why))) => HostMsg::Uncertain(why), - Err(_) => HostMsg::Refused("the host hit an internal error".into()), - } -} - /// Take run/update.lock (exclusive), if it is free now. fn take_update_lock(run: &Path) -> Result { let file = std::fs::OpenOptions::new() diff --git a/crates/ledger-host/src/lease.rs b/crates/ledger-host/src/lease.rs index cb26f9a..10fa049 100644 --- a/crates/ledger-host/src/lease.rs +++ b/crates/ledger-host/src/lease.rs @@ -4,7 +4,10 @@ //! GOAL: holders hold the books open, and the owner's few requests reach //! the server, through one socket that accepts nothing else. //! SOURCE: plan/served.md 3.9 ("Who holds them", "Three local channels": -//! leases record their kind and PID; exactly five owner intents; +//! leases record their kind and PID; the owner intents (as built, +//! D-32: two; the viewer, unconfined, backs up, restores and makes +//! books through the server's admin socket as the command line +//! does); //! consent carries the owner passphrase (a replacing restore no //! longer does: D-31, the books it replaces are kept in legacy/); //! "Closing": the host says why on each lease; protocol versions N @@ -13,9 +16,8 @@ //! 1. A connection's first line is one Ask; a Hold keeps the //! connection (the lease lasts until it closes), anything else is //! answered once and the connection closed. -//! 2. Exactly five intents exist: close, back_up_now, new_books, -//! restore (a backup's address or folder, as `books restore` takes -//! it) and consent_format_upgrade; any other request is refused. +//! 2. Exactly two intents exist: close and consent_format_upgrade; any +//! other request is refused. //! 3. consent_format_upgrade carries the owner passphrase; without one //! it is refused before anything is done. //! 4. Before the host ends a lease it sends the reason; a lease that @@ -102,36 +104,12 @@ pub enum Ask { Intent(Intent), } -/// The owner's intents: exactly these five (INVARIANT 2). +/// The owner's intents: exactly these two (INVARIANT 2). #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case", deny_unknown_fields)] pub enum Intent { /// Close the books now: every lease ends. Close, - /// Back a book up now. - BackUpNow { - /// Which. - book: String, - }, - /// Start new books. - NewBooks { - /// Their id. - id: String, - /// Their base currency. - currency: String, - }, - /// Restore a backup, as its own book or replacing one (kept in - /// legacy/). - Restore { - /// The backup: a repository's address, or its folder (`.git` - /// in the backup folder). - source: String, - /// The books' id. - id: String, - /// Replace the books of that id. - #[serde(default)] - replace: bool, - }, /// Let the server upgrade a book's journal format. ConsentFormatUpgrade { /// Which. @@ -277,12 +255,9 @@ mod tests { } #[test] - fn exactly_five_intents_are_understood() { + fn exactly_two_intents_are_understood() { for intent in [ json!("close"), - json!({"back_up_now": {"book": "acme"}}), - json!({"new_books": {"id": "acme", "currency": "USD"}}), - json!({"restore": {"source": "/backups/acme.git", "id": "acme"}}), json!({"consent_format_upgrade": {"book": "acme", "passphrase": "x"}}), ] { let h = parse(json!({"proto": 1, "intent": intent})).unwrap(); @@ -291,8 +266,9 @@ mod tests { for bad in [ json!({"proto": 1, "intent": "delete"}), json!({"proto": 1, "intent": {"restore": {"path": "/x"}}}), - json!({"proto": 1, "intent": {"restore": {"source": "/x", "id": "a", "path": "/x"}}}), - json!({"proto": 1, "intent": {"restore": {"staged": "s1"}}}), + json!({"proto": 1, "intent": {"restore": {"source": "/x", "id": "a"}}}), + json!({"proto": 1, "intent": {"back_up_now": {"book": "acme"}}}), + json!({"proto": 1, "intent": {"new_books": {"id": "acme", "currency": "USD"}}}), json!({"proto": 1, "intent": {"run": {"command": "sh"}}}), json!({"proto": 1, "exec": "sh"}), json!({"proto": 1}), @@ -325,22 +301,7 @@ mod tests { assert!(consent(None).check_passphrase().is_err()); assert!(consent(Some("")).check_passphrase().is_err()); assert!(consent(Some("pw")).check_passphrase().is_ok()); - for other in [ - // D-31: replacing keeps the old books in legacy/; no passphrase. - Intent::Restore { - source: "/backups/acme.git".into(), - id: "acme".into(), - replace: true, - }, - Intent::Close, - Intent::BackUpNow { book: "a".into() }, - Intent::NewBooks { - id: "a".into(), - currency: "USD".into(), - }, - ] { - assert!(other.check_passphrase().is_ok()); - } + assert!(Intent::Close.check_passphrase().is_ok()); } #[test] diff --git a/crates/ledger-host/src/lib.rs b/crates/ledger-host/src/lib.rs index 0534d6f..ea1e881 100644 --- a/crates/ledger-host/src/lib.rs +++ b/crates/ledger-host/src/lib.rs @@ -5,18 +5,17 @@ //! the **host**, which starts the books server and holds it open while //! any lease on its socket lasts, and closes it 10 s after the last one //! ends. Each part is a mode of the one `wanderbooks` executable: -//! [`host::run`] (`wanderbooks host`), [`helper::run`] (`wanderbooks -//! helper`), [`bridge::run`] (`wanderbooks mcp`), and the commands in -//! [`commands`] (`serve`, `close`, `books`, `token`). Agents' tokens are -//! kept in the OS keyring ([`tokens`]). +//! [`host::run`] (`wanderbooks host`), [`bridge::run`] (`wanderbooks +//! mcp`), and the owner's commands in [`commands`] (`serve`, `close`, +//! `books`, `token`, `connect`, `doctor`, `passphrase`, and what the +//! viewer's Books menu does). Agents' tokens are kept in the OS keyring +//! ([`tokens`]). #[cfg(unix)] pub mod bridge; #[cfg(unix)] pub mod commands; #[cfg(unix)] -pub mod helper; -#[cfg(unix)] pub mod host; pub mod knobs; pub mod lease; diff --git a/crates/ledger-host/src/opener.rs b/crates/ledger-host/src/opener.rs index 3cb7a5e..65b872e 100644 --- a/crates/ledger-host/src/opener.rs +++ b/crates/ledger-host/src/opener.rs @@ -12,7 +12,8 @@ //! INVARIANTS: //! 1. Before each try the holder waits while a restart holds //! run/update.lock. -//! 2. No host answering: it starts one (its Starter) and keeps trying; +//! 2. No host answering: it starts one (cleanly, start.rs) and keeps +//! trying; //! a host that is closing or restarting turns it away and it tries //! again until a host takes the lease. //! 3. A start that brings no lease within START_WAIT (or whose host @@ -87,20 +88,11 @@ impl From<&str> for HoldError { } } -/// How a holder starts a host when none answers. -pub enum Starter { - /// Start it itself (a holder that isn't confined). - Direct, - /// Ask its helper to (a confined holder). - Helper(std::sync::Arc), -} - /// Takes leases for one holder. pub struct Opener { root: Option, run: PathBuf, knobs: Knobs, - starter: Starter, kind: Kind, book: Option, failures: u32, @@ -140,7 +132,6 @@ impl Opener { pub fn new( root: Option<&Path>, knobs: Knobs, - starter: Starter, kind: Kind, book: Option, ) -> Result { @@ -150,7 +141,6 @@ impl Opener { root: root.map(|_| found.path().to_path_buf()), run, knobs, - starter, kind, book, failures: 0, @@ -192,6 +182,12 @@ impl Opener { Ok(()) } + /// Start counting failures over (a holder that waited a while after + /// giving up tries again); what it has held is kept (INVARIANT 5). + pub fn forget_failures(&mut self) { + self.failures = 0; + } + /// Take a lease, starting a host if none answers (INVARIANTS 1-4). pub async fn hold(&mut self) -> Result { let mut started: Option = None; @@ -230,8 +226,8 @@ impl Opener { // for it rather than start another. let held = host_lock_held(&self.run); match started { - // A start that fails (a spawn error, the helper - // gone) is a failure like any other (INVARIANT 3). + // A start that fails (a spawn error) is a + // failure like any other (INVARIANT 3). None if !held => match self.start().await { Ok(()) => started = Some(Instant::now()), Err(_) => self.failed().await?, @@ -256,25 +252,13 @@ impl Opener { } } - /// Start a host, through this holder's starter. + /// Start a host, cleanly (start.rs). async fn start(&self) -> Result<(), String> { - match &self.starter { - Starter::Direct => { - let root = self.root.clone(); - let knobs = self.knobs; - tokio::task::spawn_blocking(move || { - crate::start::start_host(root.as_deref(), &knobs) - }) - .await - .map_err(|e| e.to_string())? - } - Starter::Helper(helper) => { - let helper = helper.clone(); - tokio::task::spawn_blocking(move || helper.start_host()) - .await - .map_err(|e| e.to_string())? - } - } + let root = self.root.clone(); + let knobs = self.knobs; + tokio::task::spawn_blocking(move || crate::start::start_host(root.as_deref(), &knobs)) + .await + .map_err(|e| e.to_string())? } /// Say hello on a connection and read the host's first word. @@ -468,7 +452,6 @@ mod tests { backoff: Duration::from_millis(1), ..Knobs::default() }, - starter: Starter::Direct, kind: Kind::Cli, book: None, failures: 0, diff --git a/crates/ledger-mcp/src/admin.rs b/crates/ledger-mcp/src/admin.rs index 0b9e0aa..c8da8cf 100644 --- a/crates/ledger-mcp/src/admin.rs +++ b/crates/ledger-mcp/src/admin.rs @@ -116,6 +116,15 @@ pub enum Request { #[serde(default)] expect_head: Option<(u64, String)>, }, + /// Back books up now (plan 3.7): to every target their settings name. + BackUpNow { + /// The books. + id: String, + /// The backup folder's disk is new: begin these books' backup + /// there again. + #[serde(default)] + new_disk: bool, + }, /// Make a token for an agent (plan 3.3). TokenCreate { /// The books it opens. @@ -294,6 +303,21 @@ pub struct Restored { pub problem: Option, } +/// How one backup target took a backup now. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct BackedUpTarget { + /// "repository" or "folder". + pub target: String, + /// Where (no path in the data root). + pub at: String, + /// The journal seq it holds now, if any. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub through_seq: Option, + /// Why it holds no backup of the latest, if it doesn't. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub problem: Option, +} + /// The server's answer. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case", deny_unknown_fields)] @@ -316,6 +340,14 @@ pub enum Response { RestorePreview(RestorePreview), /// A backup restored, and open. Restored(Restored), + /// Books backed up now: their head, and each target's outcome (none + /// named: none set up). + BackedUp { + /// The books' head seq. + head_seq: u64, + /// Each target. + targets: Vec, + }, /// A token made: its text, shown this once (INVARIANT 6). TokenCreated { /// The token. @@ -371,6 +403,9 @@ pub fn call_within( Request::Create { .. } | Request::Adopt { .. } | Request::Restore { .. } => { Some("`wanderbooks books list` shows the books there are") } + Request::BackUpNow { .. } => { + Some("`wanderbooks books back-up` again backs up whatever it didn't") + } Request::TokenCreate { .. } | Request::TokenRevoke { .. } => { Some("`wanderbooks token list` shows the tokens there are") } diff --git a/crates/ledger-mcp/src/local.rs b/crates/ledger-mcp/src/local.rs index 2efb578..2948133 100644 --- a/crates/ledger-mcp/src/local.rs +++ b/crates/ledger-mcp/src/local.rs @@ -490,6 +490,41 @@ impl Books { confirmed, expect_head, } => self.restore(id, source, replace, confirmed, expect_head), + Request::BackUpNow { id, new_disk } => { + let id = match BookId::parse(&id) { + Ok(id) => id, + Err(e) => return refused(e), + }; + let backups = match self.held().get(id.as_str()) { + Some(Book::Open { backups, .. }) => backups.clone(), + Some(Book::Failed(why)) => { + return Response::Refused(format!("those books didn't open: {why}")); + } + None => { + return Response::Refused(format!( + "there are no books called {:?}", + id.as_str() + )); + } + }; + let rt = tokio::runtime::Handle::current(); + match rt.block_on(backups.run_with("asked", new_disk)) { + Ok(status) => Response::BackedUp { + head_seq: status.head_seq, + targets: status + .targets + .into_iter() + .map(|t| admin::BackedUpTarget { + target: t.target, + at: t.at, + through_seq: t.through_seq, + problem: t.last_error, + }) + .collect(), + }, + Err(why) => Response::Refused(self.hide(why)), + } + } Request::Adopt { id, source, diff --git a/crates/ledger-mcp/src/tools/kept.rs b/crates/ledger-mcp/src/tools/kept.rs index c572a14..c1c1bde 100644 --- a/crates/ledger-mcp/src/tools/kept.rs +++ b/crates/ledger-mcp/src/tools/kept.rs @@ -237,6 +237,10 @@ pub struct StartOut { pub handbook_sections: Vec, /// What to do next, in order. pub next_steps: Vec, + /// How the books are kept from agents: "same_user (advisory)", hidden + /// from them in the human's own account, not locked against them + /// (plan D-32): reach them only through these tools. + pub isolation: String, } /// One setting change. @@ -894,6 +898,7 @@ impl BooksServer { }) .collect(), next_steps, + isolation: "same_user (advisory)".to_string(), }; // Fit (INVARIANT 4): whole lists if they fit; else summaries go, // then the lists shorten from the end, each saying how many more. diff --git a/crates/ledger-mcp/tests/spec_kept.rs b/crates/ledger-mcp/tests/spec_kept.rs index ff5c162..55e6360 100644 --- a/crates/ledger-mcp/tests/spec_kept.rs +++ b/crates/ledger-mcp/tests/spec_kept.rs @@ -517,6 +517,8 @@ fn start_fits_in_8_kb() { let cut = start["truncated"].to_string(); assert!(cut.contains("books.conventions"), "{cut}"); assert!(start["conventions"].as_array().unwrap().len() >= 10); + // isolation_label_is_advisory (plan P2, D-32). + assert_eq!(start["isolation"], "same_user (advisory)"); } /// Review of P1 (2026-10-17): the text imported is the file the server diff --git a/docs/mcp-interface.md b/docs/mcp-interface.md index 9a65426..adc6d6d 100644 --- a/docs/mcp-interface.md +++ b/docs/mcp-interface.md @@ -130,7 +130,9 @@ settings-and-conventions section says how to use them. evidence, parties, approval_rules }, settings_summary: [{ key, value, set_by, seq }], conventions: [{ id, kind, title, summary }], open_items: [{ id, title, due }], truncated, handbook_sections, next_steps: [{ tool, - args?, why }] }`. The connect text names it as the first call. Lists cut + args?, why }], isolation }`. The connect text names it as the first call. + `isolation` is `same_user (advisory)` (D-32): the books are hidden from + agents in the owner's own account, not locked against them. Lists cut to fit say so in `truncated` ("conventions: 4 more (books.conventions)"). `focus` adds steps: `onboarding` the onboarding step, `close` the company's close steps, `audit` `books.verify`. Not built yet (P5): diff --git a/docs/spec/rules.md b/docs/spec/rules.md index c71a160..5ebd9c9 100644 --- a/docs/spec/rules.md +++ b/docs/spec/rules.md @@ -906,7 +906,6 @@ checked to change no figure or entry; `approval_rules_off_by_default`, `a_newer_key_or_kind_is_newer`; `books_written_under_p1_open_to_their_record`, fixture `old-books/p1_record`); `ledger-config` `tests/codec_props.rs` -(`exports_round_trip`) and codec unit tests; `ledger-cli` `tests/init.rs` -`the_toml_and_its_examples_import_and_round_trip`; `ledger-mcp` +(`exports_round_trip`) and codec unit tests; `ledger-mcp` `tests/spec_kept.rs`; Lean `Step.record_changes_no_figure`, `Reachable.records`; fuzz `fuzz_settings_replay`. diff --git a/install.sh b/install.sh index dd5c969..436cf36 100755 --- a/install.sh +++ b/install.sh @@ -1,14 +1,16 @@ #!/bin/sh # wanderbooks installer. One line on a new machine: # -# curl -fsSL https://wanderbooks.ai/install.sh | sh -s -- ~/Books/Acme +# curl -fsSL https://wanderbooks.ai/install.sh | sh -s -- acme # -# Linux and macOS. On Windows, run it inside WSL2 (Ubuntu): the GUI opens as -# a normal window on Windows 11. +# Linux and macOS. On Windows, run it inside WSL2 (Ubuntu): the viewer opens +# as a normal window on Windows 11. # -# Installs Rust if needed, then the wanderbooks CLI, MCP server, and GUI -# (into ~/.cargo/bin), then — given a directory — makes your books there and -# starts the agent. Without a directory it just installs. +# Installs Rust if needed, then the one `wanderbooks` program (into +# ~/.cargo/bin), then makes the books' private folder (`wanderbooks setup`). +# Given a name (lowercase letters, digits, hyphens), it also starts books of +# that name and lets Claude Code reach them. Nothing runs as root; the +# books close on their own a few seconds after the last command. # # WANDERBOOKS_REPO overrides where it installs from (a git URL). set -eu @@ -54,27 +56,32 @@ if ! command -v cargo >/dev/null 2>&1; then fi say "Installing wanderbooks from $REPO (a few minutes, once)…" -cargo install --locked --git "$REPO" ledger-cli ledger-mcp ledger-gui - -if ! command -v just >/dev/null 2>&1; then - say "Installing just (for the books dir's shortcuts)…" - cargo install --locked just -fi - -if ! command -v claude >/dev/null 2>&1; then - say "Claude Code isn't installed yet: https://claude.com/claude-code" -fi +cargo install --locked --git "$REPO" ledger-cli case ":$PATH:" in *":$HOME/.cargo/bin:"*) ;; *) say "Add ~/.cargo/bin to your PATH (rustup's installer usually does; open a new terminal)." ;; esac -if [ $# -gt 0 ]; then - # Hand the terminal to the agent when there is one. - if [ -t 1 ] && [ -e /dev/tty ]; then - exec "$HOME/.cargo/bin/wanderbooks" new "$@" /dev/null 2>&1; then + "$wb" connect claude --book "$1" + say "Done. Start Claude Code and say hello: your books \"$1\" are ready." + else + say "Done. Install Claude Code (https://claude.com/claude-code), then: wanderbooks connect claude --book $1" + fi + exit 0 +fi +say "Done. Start books with: wanderbooks books create acme then: wanderbooks connect claude --book acme" diff --git a/plan/served.md b/plan/served.md index 0838401..f6134d0 100644 --- a/plan/served.md +++ b/plan/served.md @@ -343,6 +343,14 @@ The books are open while something holds them; there is no background service. T **Format changes are the server's gate, not the updater's.** A server that would write a newer journal format than a book has opens that book read-only, with guard `format_upgrade_pending`, until the owner consents in the app (or on a `/h/` page). Before it asks, it makes a verified local backup that includes the keys (`legacy.key` until P7). Consent comes only from the owner (the passphrase, through the viewer's `consent_format_upgrade` or a `/h/` page), never from an agent. Declining leaves the book as it was, and before consent the way back to a version that can write it is always there: a self-updating install keeps the previous verified app and offers "Go back to version X" (`wanderbooks rollback`); a package-managed one downgrades through its package manager (our apt and dnf repositories keep every release, and the AUR keeps old PKGBUILDs). This holds however the new version arrived: our updater, a package manager, or a hand install. **After consent** the previous version can no longer open that book, so "Go back to version X" refuses for it; the only way back is restoring the pre-upgrade backup, which would drop every entry since, so that restore says how many entries would be lost, arrives as its own book beside the current one (never replacing it), and needs the passphrase. +**As built (P2 slice 8), and where it departs (D-32):** +- **No confinement, no helper.** No mode confines itself (no Landlock, no seccomp), so the helper is gone (`wanderbooks helper`, `ledger-host` helper.rs): the bridge, like every holder, starts the host itself with the clean start (start.rs). The host still refuses to run when it finds itself confined (started from a sandboxed app). Isolation is said as `same_user (advisory)` in `books.start` (`isolation`) and `wanderbooks doctor`. +- **The lease socket's intents are two:** `close` and `consent_format_upgrade`. Backing up, restoring and new books were intents only so a confined viewer could hand them over; the viewer now does them as the command line does, through the server's admin socket (`back_up_now`, `restore`, `create`), holding a lease while it asks. +- **The viewer holds the books open** (a lease of kind app) while it shows books of the data root, on a thread of its own, taken only once its own flags are good and its book is found (the window never waits; with no server it still opens and follows the books from disk). It lets go only when the owner closes the books; books that stop or won't open are tried again (a fresh try every 30 s after five failures). Its **Books** menu (ledger-gui owner.rs, beside Export): Back up now; Restore… (the backup's address or folder typed, checked first: what it would do is shown, then done for that backup at that head); New books…; Connect Claude Code, Connect Codex; Close the books (asked first). Each is done off the UI thread by the `Owner` ledger-cli gives the viewer, over the same ledger-host functions the command line uses; the viewer writes nothing of the books; the menu waits while one runs, and a restore's fields can't change while it is checked (a result for anything since edited is dropped). Older books dirs and the demo have no Books menu. +- **New commands:** `wanderbooks connect claude|codex --book ID` (the books' agent token kept in the keyring, made if none is, replacing the server's, which no one can be using; then the agent's own `claude mcp add --scope user` or `codex mcp add` of `wanderbooks-` running `wanderbooks mcp --book `; the agent's command found on the PATH, else where its installers put it under `$HOME`, or `/usr/local/bin`, `/opt/homebrew/bin`: a viewer started from the desktop has a short PATH), and `wanderbooks books back-up ID [--new-disk]` (the admin socket's `back_up_now`). +- **Scaffold deleted:** `wanderbooks new`/`init` and its templates (justfile, AGENTS.md, CLAUDE.md, wanderbooks.toml, .gitignore, .mcp.json, Claude and Codex settings), and its tests; the settings file's own export is ledger-config's (`books.export_settings`). `wanderbooks new`/`init` now says it is gone and what to run instead. `install.sh` installs the one executable, runs `setup`, and given a name starts those books and connects Claude Code: three commands to the first post, no sudo. The website (`site/index.html`) says the same; its built copy must be redeployed (`just deploy`) when this change is pushed: the old installer runs `wanderbooks new`. The first company's own books dir keeps its files until the cutover (slice 9). +- **Tests:** `ledger-gui/tests/owner_menu.rs` (each item through a fake owner, the restore checked first, nothing of the books written; busy states and a restore's fields frozen while checked; no menu for an older books dir; screenshots), `open_while_held.rs` (connect with fake agents and the command it registers reaching the books, a token the keyring lost, an agent that refuses; back up now from the command line, `--new-disk`; the viewer's hold, new books and close through the owner's functions; the two intents), `one_executable.rs` (the gone scaffold), `spec_kept.rs` (`isolation`). + ## 4. The MCP surface after the change ### 4.1 Connect instructions (composed in `get_info`; replaces `INSTRUCTIONS` S:57 and the string replace at S:8179) @@ -1248,7 +1256,7 @@ The owner decisions of 2026-10-16 (at the top of this document) replace the form | D-29 | The full-TIN outputs, from both doors (owner, 2026-10-19, asked whether the agent's CPA export should go away once the viewer has one: "Both WITH TINs. Stop acting like TINS MATTER SO MUCH THEY DONT") | **The agent and the viewer both make them, TINs included.** `books.export_cpa_package` (TINs by default), `books.vendor_1099` with `include_tins` and `books.get_w9` keep working for the agent; their files are made by the server and handed over by a command, `wanderbooks download`, like a receipt too large for a call (no path parameter, nothing in the agent's context). The viewer's menu makes the same files into Downloads. Over stdio (the development transport) the file comes back inline. TINs get no handling beyond the vault: no passphrase, no extra prompts, no special cleanup. | | D-30 | Q-1 answered: where the backups go (owner, 2026-10-19, asked "Where should the books' automatic backups go?" with a private git repo, a folder or other disk, or both: chose "Both") | **Two targets.** The archive pushes to a private git repository the owner names and also copies to a folder the owner names (an external disk or a synced folder); both hold the archive (not encrypted since D-31). The repository's address and the folder are asked for when backups are first set up (slice 7, or at the cutover for the first company), and kept as the book's backup settings. | | D-31 | Backups are not encrypted (owner, 2026-10-19, asked where to keep the backup's recovery key: "Why encrypt them? How about we let the backup system handle the security???? You're over complicating everything.") | **A backup is the book as it is, in git.** No age encryption, no archive key, no recovery key: the archive is a plain git repository of the book's journal, receipts, vault (as the vault keeps it) and keys, one commit per backup, pushed to the owner's private git repository and to a git repository in the owner's folder (D-30). The destinations are trusted to keep it private. This replaces 3.7's and 3.4's age encryption on push and 3.5's archive key. | -| D-32 | No self-confinement (owner, 2026-10-20, asked how much of the planned Landlock/seccomp sandboxing of the server and the viewer, with the viewer's helper process, to build now that the server runs git for its backups: chose "None") | **No Landlock, no seccomp, no helper process for confinement.** The viewer stays read-only by design and by test (it never writes the books; its menu hands work to the server through the lease socket); agents still reach the books only through the MCP. Isolation is reported everywhere as `same_user (advisory)`. This replaces the self-confinement of P2's scope, 3.9's confined modes and their helper, and the archive-vs-Landlock decision row. | +| D-32 | No self-confinement (owner, 2026-10-20, asked how much of the planned Landlock/seccomp sandboxing of the server and the viewer, with the viewer's helper process, to build now that the server runs git for its backups: chose "None") | **No Landlock, no seccomp, no helper process for confinement.** The viewer stays read-only by design and by test (it never writes the books; its menu hands work to the server, through its admin socket as the command line does); agents still reach the books only through the MCP. Isolation is reported everywhere as `same_user (advisory)`. This replaces the self-confinement of P2's scope, 3.9's confined modes and their helper, and the archive-vs-Landlock decision row. | ### 10.2 Deferred diff --git a/site/index.html b/site/index.html index e15d63b..9a655de 100644 --- a/site/index.html +++ b/site/index.html @@ -153,10 +153,10 @@ footer { padding: 72px 0 48px; color: var(--faint); font-size: 14px; display: fl
$ - curl -fsSL https://wanderbooks.ai/install.sh | sh -s -- ~/Books/YourCompany + curl -fsSL https://wanderbooks.ai/install.sh | sh -s -- yourcompany
-

One line installs everything, makes your books, and starts your bookkeeper in Claude Code. Using Codex? Add --codex to the end. Linux and macOS; Windows via WSL2.

+

One line installs everything, starts your books (a short name: lowercase letters, digits and hyphens), and connects Claude Code to them; then start Claude Code and say hello. Using Codex? Run wanderbooks connect codex --book yourcompany after. Linux and macOS; Windows via WSL2.

The wanderbooks viewer's overview: cash, what you owe, spending, revenue, runway, a cash chart, and what needs attention
@@ -289,13 +289,13 @@ footer { padding: 72px 0 48px; color: var(--faint); font-size: 14px; display: fl

Install

-

The one line at the top does everything: installs Rust if you don't have it, builds wanderbooks, makes your books folder with the bookkeeper wired in, and starts a session to set up your company.

+

The one line at the top does everything: installs Rust if you don't have it, builds wanderbooks, makes a private folder for your books, starts them, and connects Claude Code. Nothing needs administrator rights, and nothing keeps running in the background once you're done.

Linux

Needs a C compiler and git (build-essential on Debian and Ubuntu). The installer tells you if anything's missing.

macOS

Needs Apple's command line tools: xcode-select --install.

Windows

Run it inside WSL2 (wsl --install, then Ubuntu). On Windows 11 the viewer opens as a normal window. Native Windows is on the way.

-

Already installed? wanderbooks new ~/Books/AnotherCompany. In a books folder, just agent (or just codex) starts your bookkeeper and just gui opens the viewer.

+

Already installed? wanderbooks books create another, then wanderbooks connect claude --book another. wanderbooks gui opens the viewer; its Books menu backs up, restores, starts new books and closes them.

@@ -304,7 +304,7 @@ footer { padding: 72px 0 48px; color: var(--faint); font-size: 14px; display: fl
Wait, what's an MCP server?

MCP is the standard way AI tools like Claude Code and Codex plug into other software. wanderbooks is an accounting engine that speaks it: your AI calls its tools to draft entries, attach receipts, and run reports, and the engine enforces the accounting rules. The one-line installer sets it up; you never have to touch it.

Do I need to be technical?

You need to paste one command into a terminal, once. After that you talk to your bookkeeper in plain English, in Claude Code or Codex, and look at your books in the viewer whenever you like.

How does it find my receipts?

Give your AI access to your email (in Claude, an email connector like Gmail's or the Claude browser extension with your mail open) and ask. It searches for the vendor and amount, files one clean PDF per receipt, and attaches it to the entry. It asks before downloading anything, and it never types passwords or account numbers.

-
Where's my data, and what does the AI see?

Your books are files on your computer: a journal, your receipts, and a settings file, in a git repository you control. Nothing is uploaded to wanderbooks; there is no wanderbooks server. What your bookkeeper reads to help you goes to your AI provider (Anthropic or OpenAI), like any other conversation with it.

+
Where's my data, and what does the AI see?

Your books are files in a private folder on your computer: a journal and your receipts. They back themselves up to a private git repository and a folder you choose. Nothing is uploaded to wanderbooks; there is no wanderbooks server. What your bookkeeper reads to help you goes to your AI provider (Anthropic or OpenAI), like any other conversation with it.

What if the AI gets something wrong?

Every posting is previewed before it happens. If something slips through, it's corrected the way accountants do it, with a reversing entry, so the mistake and the fix are both on the record. The engine itself refuses anything that doesn't balance, anything without a required receipt, and anything in a closed period.

Is this real accounting, or a spreadsheet with an AI on top?

Real double-entry accounting, with the rules enforced in code: exact decimal money, balanced entries, immutable posting, closes, depreciation, accrual or cash basis. Its statements are tested against hand-computed numbers, golden files, and property tests.

Can I bring my history from QuickBooks or a spreadsheet?

Yes. Export your chart of accounts, journal, and reports, and ask your bookkeeper to move you over. It rebuilds the history and compares every statement against the old system, to the cent, before calling it done. Keep the old system read-only until you've closed a month in wanderbooks.

@@ -320,7 +320,7 @@ footer { padding: 72px 0 48px; color: var(--faint); font-size: 14px; display: fl (function () { var cmd = document.getElementById("cmd"); if (location.protocol === "https:" && location.hostname !== "wanderbooks.ai") { - cmd.textContent = "curl -fsSL " + location.origin + "/install.sh | sh -s -- ~/Books/YourCompany"; + cmd.textContent = "curl -fsSL " + location.origin + "/install.sh | sh -s -- yourcompany"; } document.getElementById("copy").addEventListener("click", function () { var b = this;