ledger_store::root: one private folder for every book, found without the environment (--root, else the root setup recorded in a file only this account can write, else the platform default under the home from the password database). Every folder made 0700, files 0600; a root that is a link, another user's, open to others, unmarked, or under a folder another user could swap is refused; no error names it. Setup keeps a recorded root, never makes a second root or moves books while one holds any, and leaves a folder holding other things untouched. Books are created and adopted one at a time; adoption reads the source under its writer lock (changing nothing in it), copies into staging, re-hashes every stored file against its name, checks every named file is there, opens and verifies the copy with the source's head, imports settings for books that keep none, syncs, and only then renames it into place. wanderbooks setup makes the root and never prints where it is. Three review rounds; mutation on the diff (docs/testing.md). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
This commit is contained in:
11 files changed
+2629
-10
No files matched your search
Generated
+2
@@ -3657,6 +3657,7 @@ dependencies = [
|
||||
"ledger-core",
|
||||
"ledger-gui",
|
||||
"ledger-mcp",
|
||||
"ledger-store",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"toml 1.1.8+spec-1.1.0",
|
||||
@@ -3774,6 +3775,7 @@ dependencies = [
|
||||
"criterion",
|
||||
"hex",
|
||||
"ledger-core",
|
||||
"libc",
|
||||
"proptest",
|
||||
"rusqlite",
|
||||
"rust_decimal",
|
||||
|
||||
@@ -18,6 +18,7 @@ gui = ["dep:ledger-gui"]
|
||||
|
||||
[dependencies]
|
||||
ledger-mcp = { path = "../ledger-mcp" }
|
||||
ledger-store = { path = "../ledger-store" }
|
||||
ledger-gui = { path = "../ledger-gui", optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
//! wanderbooks new [DIR] [--codex] [--force] [--no-start] (alias: init)
|
||||
//! wanderbooks gui [DIR | 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
|
||||
@@ -392,10 +393,12 @@ fn print_usage() {
|
||||
eprintln!("usage: wanderbooks new [DIR] [--codex] [--force] [--no-start]");
|
||||
eprintln!(" wanderbooks gui [DIR | --data-dir PATH ...]");
|
||||
eprintln!(" wanderbooks dev-stdio [--data-dir PATH ...]");
|
||||
eprintln!(" wanderbooks setup [--root PATH] [--home DIR]");
|
||||
eprintln!();
|
||||
eprintln!(" new make a books directory and start the agent in it (alias: init)");
|
||||
eprintln!(" gui open the live, read-only view of a books directory");
|
||||
eprintln!(" 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!(" 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");
|
||||
@@ -795,6 +798,78 @@ fn run_gui(_args: Vec<String>) -> i32 {
|
||||
2
|
||||
}
|
||||
|
||||
/// `wanderbooks setup [--root PATH] [--home DIR]`: make the books'
|
||||
/// private folder (plan 3.4): `--root` if given, else
|
||||
/// `$XDG_DATA_HOME/wanderbooks`, else the platform's default under the
|
||||
/// account's home (`--home`, else the system's user records). The folder's
|
||||
/// location is never printed: nothing an agent is shown names it.
|
||||
fn run_setup(args: Vec<String>) -> i32 {
|
||||
let mut root: Option<PathBuf> = None;
|
||||
let mut home: Option<PathBuf> = None;
|
||||
let mut args = args.into_iter();
|
||||
while let Some(arg) = args.next() {
|
||||
let slot = match arg.as_str() {
|
||||
"--root" => &mut root,
|
||||
"--home" => &mut home,
|
||||
"--help" | "-h" => {
|
||||
eprintln!("usage: wanderbooks setup [--root PATH] [--home DIR]");
|
||||
eprintln!(
|
||||
" --root where the books' private folder goes (default: your data folder)"
|
||||
);
|
||||
eprintln!(" --home the account's home (default: from the system's user records)");
|
||||
return 0;
|
||||
}
|
||||
other => {
|
||||
eprintln!("unknown argument: {other}");
|
||||
eprintln!("usage: wanderbooks setup [--root PATH] [--home DIR]");
|
||||
return 2;
|
||||
}
|
||||
};
|
||||
match args.next() {
|
||||
Some(value) => *slot = Some(PathBuf::from(value)),
|
||||
None => {
|
||||
eprintln!("error: {arg} needs a path");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
let account = ledger_store::root::account_home().ok();
|
||||
let home = match home.or_else(|| account.clone()) {
|
||||
Some(home) => home,
|
||||
None => {
|
||||
eprintln!("error: {}", ledger_store::root::RootError::NoHome);
|
||||
return 1;
|
||||
}
|
||||
};
|
||||
if account.as_ref() != Some(&home) {
|
||||
eprintln!(
|
||||
"note: --home isn't this account's home, so this account's books server won't \
|
||||
look there (tests and unusual setups only)"
|
||||
);
|
||||
}
|
||||
let xdg = env::var_os("XDG_DATA_HOME").map(PathBuf::from);
|
||||
match ledger_store::root::Root::setup(&home, root.as_deref(), xdg.as_deref()) {
|
||||
Ok((_, report)) => {
|
||||
let what = if report.created {
|
||||
"made"
|
||||
} else if report.tightened {
|
||||
"already there; made private again (other users could open it)"
|
||||
} else {
|
||||
"already there"
|
||||
};
|
||||
println!("The books' private folder: {what}. Only your account can open it.");
|
||||
if report.recorded {
|
||||
println!("It isn't in the default place, so where it is was noted for next time.");
|
||||
}
|
||||
0
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("error: {e}");
|
||||
1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `wanderbooks dev-stdio`: the books server over stdio, in this process.
|
||||
fn run_dev_stdio(args: Vec<String>) -> i32 {
|
||||
match ledger_mcp::run("wanderbooks dev-stdio", args) {
|
||||
@@ -815,6 +890,7 @@ fn main() {
|
||||
match all.next().as_deref() {
|
||||
Some("gui") => std::process::exit(run_gui(all.collect())),
|
||||
Some("dev-stdio") => std::process::exit(run_dev_stdio(all.collect())),
|
||||
Some("setup") => std::process::exit(run_setup(all.collect())),
|
||||
_ => {}
|
||||
}
|
||||
let mut dir: Option<PathBuf> = None;
|
||||
@@ -845,7 +921,7 @@ fn main() {
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(mode @ ("gui" | "dev-stdio")) = cmd.as_deref() {
|
||||
if let Some(mode @ ("gui" | "dev-stdio" | "setup")) = cmd.as_deref() {
|
||||
eprintln!("`{mode}` must come first: wanderbooks {mode} [its own flags]");
|
||||
std::process::exit(2);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
//! `wanderbooks setup`: the books' private folder, made from the command
|
||||
//! line, with its location never printed.
|
||||
#![cfg(unix)]
|
||||
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::process::Command;
|
||||
|
||||
fn wanderbooks() -> Command {
|
||||
Command::new(env!("CARGO_BIN_EXE_wanderbooks"))
|
||||
}
|
||||
|
||||
fn mode(path: &std::path::Path) -> u32 {
|
||||
std::fs::metadata(path).unwrap().permissions().mode() & 0o777
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn setup_makes_the_private_folder_and_never_names_it() {
|
||||
let home = tempfile::Builder::new()
|
||||
.prefix("wb-setup-")
|
||||
.tempdir()
|
||||
.unwrap();
|
||||
let root = home.path().join(".local/share/wanderbooks");
|
||||
let out = wanderbooks()
|
||||
.args(["setup", "--home"])
|
||||
.arg(home.path())
|
||||
.env_remove("XDG_DATA_HOME")
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(out.status.success(), "{out:?}");
|
||||
let text = String::from_utf8_lossy(&out.stdout);
|
||||
assert!(text.contains("The books' private folder: made"), "{text}");
|
||||
assert!(
|
||||
String::from_utf8_lossy(&out.stderr).contains("--home isn't this account's home"),
|
||||
"{out:?}"
|
||||
);
|
||||
assert!(
|
||||
!text.contains(home.path().to_str().unwrap()),
|
||||
"names the folder: {text}"
|
||||
);
|
||||
assert!(!String::from_utf8_lossy(&out.stderr).contains(home.path().to_str().unwrap()));
|
||||
assert_eq!(mode(&root), 0o700);
|
||||
for sub in ["books", "run", "legacy"] {
|
||||
assert_eq!(mode(&root.join(sub)), 0o700, "{sub}");
|
||||
}
|
||||
// Again: already there.
|
||||
let out = wanderbooks()
|
||||
.args(["setup", "--home"])
|
||||
.arg(home.path())
|
||||
.env_remove("XDG_DATA_HOME")
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
String::from_utf8_lossy(&out.stdout).contains("already there."),
|
||||
"{out:?}"
|
||||
);
|
||||
// Others could open it: made private again.
|
||||
std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
let out = wanderbooks()
|
||||
.args(["setup", "--home"])
|
||||
.arg(home.path())
|
||||
.env_remove("XDG_DATA_HOME")
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
String::from_utf8_lossy(&out.stdout).contains("made private again"),
|
||||
"{out:?}"
|
||||
);
|
||||
assert_eq!(mode(&root), 0o700);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_root_elsewhere_is_noted_and_xdg_is_honoured() {
|
||||
let home = tempfile::Builder::new()
|
||||
.prefix("wb-setup-xdg-")
|
||||
.tempdir()
|
||||
.unwrap();
|
||||
let xdg = home.path().join("xdg");
|
||||
let out = wanderbooks()
|
||||
.args(["setup", "--home"])
|
||||
.arg(home.path())
|
||||
.env("XDG_DATA_HOME", &xdg)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(out.status.success(), "{out:?}");
|
||||
assert!(
|
||||
String::from_utf8_lossy(&out.stdout).contains("noted for next time"),
|
||||
"{out:?}"
|
||||
);
|
||||
assert_eq!(mode(&xdg.join("wanderbooks")), 0o700);
|
||||
let noted =
|
||||
std::fs::read_to_string(home.path().join(".local/share/wanderbooks.location")).unwrap();
|
||||
assert_eq!(
|
||||
noted.trim_end(),
|
||||
xdg.join("wanderbooks").display().to_string()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn setup_refuses_what_it_does_not_know_and_unsafe_folders() {
|
||||
let out = wanderbooks().args(["setup", "--bogus"]).output().unwrap();
|
||||
assert_eq!(out.status.code(), Some(2));
|
||||
let out = wanderbooks().args(["setup", "--root"]).output().unwrap();
|
||||
assert_eq!(out.status.code(), Some(1));
|
||||
assert!(String::from_utf8_lossy(&out.stderr).contains("--root needs a path"));
|
||||
let home = tempfile::Builder::new()
|
||||
.prefix("wb-setup-link-")
|
||||
.tempdir()
|
||||
.unwrap();
|
||||
let real = home.path().join("real");
|
||||
std::fs::create_dir(&real).unwrap();
|
||||
std::os::unix::fs::symlink(&real, home.path().join("link")).unwrap();
|
||||
let out = wanderbooks()
|
||||
.args(["setup", "--home"])
|
||||
.arg(home.path())
|
||||
.arg("--root")
|
||||
.arg(home.path().join("link"))
|
||||
.output()
|
||||
.unwrap();
|
||||
assert_eq!(out.status.code(), Some(1), "{out:?}");
|
||||
assert!(String::from_utf8_lossy(&out.stderr).contains("is a symlink"));
|
||||
// A mode comes first.
|
||||
let out = wanderbooks().args(["--force", "setup"]).output().unwrap();
|
||||
assert_eq!(out.status.code(), Some(2));
|
||||
}
|
||||
@@ -17,12 +17,18 @@ thiserror = "2"
|
||||
sha2 = "0.10"
|
||||
hex = "0.4"
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
proptest = "1"
|
||||
rust_decimal = "1"
|
||||
criterion = "0.8.2"
|
||||
|
||||
[target.'cfg(unix)'.dev-dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
[[bench]]
|
||||
name = "store"
|
||||
harness = false
|
||||
|
||||
@@ -24,6 +24,7 @@ pub mod format;
|
||||
pub mod journal;
|
||||
pub mod meta;
|
||||
pub mod reconcilable;
|
||||
pub mod root;
|
||||
pub mod schema;
|
||||
pub mod snapshot;
|
||||
pub mod store;
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -88,6 +88,12 @@ fn lock_books(data_dir: &Path) -> Result<std::fs::File, StoreError> {
|
||||
Err(std::fs::TryLockError::Error(e)) => Err(StoreError::Io(e)),
|
||||
}
|
||||
}
|
||||
/// Take a data dir's writer lock to copy it (adoption, `root.rs`): held,
|
||||
/// no other writer can append while the copy is made.
|
||||
pub(crate) fn lock_books_for_adoption(data_dir: &Path) -> Result<std::fs::File, StoreError> {
|
||||
lock_books(data_dir)
|
||||
}
|
||||
|
||||
pub(crate) const BLOBS_DIR: &str = "blobs";
|
||||
|
||||
/// One row of an entry listing.
|
||||
@@ -366,8 +372,8 @@ impl Store {
|
||||
let _ = std::fs::remove_file(db_path.with_extension("db-wal"));
|
||||
let _ = std::fs::remove_file(db_path.with_extension("db-shm"));
|
||||
eprintln!(
|
||||
"wanderbooks: sqlite projection {why}; quarantined to {} and rebuilding from journal",
|
||||
quarantine.display()
|
||||
"wanderbooks: sqlite projection {why}; quarantined to {} beside it and rebuilding from journal",
|
||||
quarantine.file_name().unwrap_or_default().to_string_lossy()
|
||||
);
|
||||
fresh_db = true;
|
||||
}
|
||||
@@ -401,9 +407,9 @@ impl Store {
|
||||
)?;
|
||||
apply_line_to_sqlite(&store.conn, &line)?;
|
||||
eprintln!(
|
||||
"wanderbooks: the journal's last line was cut off (an append that never finished); set aside {} bytes in {}",
|
||||
"wanderbooks: the journal's last line was cut off (an append that never finished); set aside {} bytes in {} beside it",
|
||||
t.bytes,
|
||||
t.saved_as.display()
|
||||
t.saved_as.file_name().unwrap_or_default().to_string_lossy()
|
||||
);
|
||||
}
|
||||
Ok(store)
|
||||
@@ -3709,8 +3715,8 @@ impl Store {
|
||||
let keep = self.db_path.with_extension(format!("mismatch.{stamp}"));
|
||||
std::fs::copy(&self.db_path, &keep)?;
|
||||
eprintln!(
|
||||
"wanderbooks: {why}; kept the old database as {} and rebuilt from the journal",
|
||||
keep.display()
|
||||
"wanderbooks: {why}; kept the old database as {} beside it and rebuilt from the journal",
|
||||
keep.file_name().unwrap_or_default().to_string_lossy()
|
||||
);
|
||||
self.rebuild()?;
|
||||
self.verify()?;
|
||||
@@ -3729,6 +3735,16 @@ impl Store {
|
||||
})
|
||||
}
|
||||
|
||||
/// The SHA-256 of every stored file the books name (receipts and
|
||||
/// documents, withdrawn ones included).
|
||||
pub fn stored_blob_shas(&self) -> Result<Vec<String>, StoreError> {
|
||||
let mut stmt = self.conn.prepare("SELECT sha256 FROM blobs")?;
|
||||
let shas = stmt
|
||||
.query_map([], |r| r.get::<_, String>(0))?
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
Ok(shas.into_iter().map(|s| s.to_lowercase()).collect())
|
||||
}
|
||||
|
||||
/// Verify every blob's bytes against its SHA-256. Returns the count
|
||||
/// checked. Reads all bytes; run on a schedule, not per request.
|
||||
pub fn verify_blobs(&self) -> Result<u64, StoreError> {
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -379,6 +379,33 @@ in the default build and the `run_gui` arms again in the server-only
|
||||
build (`--no-default-features`), every one caught in the build where its
|
||||
code exists.
|
||||
|
||||
## P2 slice 2: the data root (2026-10-18)
|
||||
|
||||
`ledger_store::root` (plan 3.4, 5.5) and `wanderbooks setup`:
|
||||
`tests/root.rs` (32), the module's unit tests (4), `ledger-cli/tests/setup.rs`
|
||||
(3). Three review rounds (12 findings, then 5, then 1, all fixed).
|
||||
Mutation on the diff, the viewer left out, two jobs: the CLI, 7 of 7
|
||||
caught; `root.rs`, 47 missed on the first run, then new tests (the blob
|
||||
copier's strays, errors other than not-found, the buffer that grows
|
||||
behind `getpwuid_r`/`getgrgid_r`, the private-group rule) and a rerun:
|
||||
201 mutants, 163 caught, 23 unviable, 1 timeout (the growing buffer
|
||||
halved instead of doubled, which never ends), and 14 left, none
|
||||
reachable by a test:
|
||||
- the lookups' failure guards (`rc != 0 || null || null` with `&&`,
|
||||
five): on every lookup that succeeds they agree, and this account's
|
||||
own lookup always succeeds;
|
||||
- `NotFound` guards whose other errors end the same way (setup's
|
||||
`symlink_metadata` of the chosen root, where creating fails with the
|
||||
same error; the location note's removal, unreachable after the note
|
||||
was checked to be a plain file; a subfolder's lookup inside a root
|
||||
already opened);
|
||||
- `create_book`'s clean-up after `Store::create` fails in a folder it
|
||||
just made (no test can make that fail);
|
||||
- `make_private_dirs`' `AlreadyExists` guard (two setups racing);
|
||||
- `sync_dir` (durability, not visible to a test);
|
||||
- the group's member check (`!` deleted), which agrees on every account
|
||||
whose own group lists no members, the usual case.
|
||||
|
||||
## Benchmarks (baseline 2026-10-13)
|
||||
|
||||
No budget, by decision (Stephen, 2026-10-13: the books do little work
|
||||
|
||||
+3
-3
@@ -216,7 +216,7 @@ No scope unwraps the vault. Unwrap happens only inside an `/h/` request that has
|
||||
archive.git/ bare; journal.jsonl + blobs/ + vault ciphertext; age-encrypted on push; destination Q-1
|
||||
```
|
||||
|
||||
The server, the CLI and the GUI resolve the data root with one shared function (`ledger-store::paths::data_root()`: the platform data directory, overridable with `--root`), never from the working directory, a project directory or a toml file. The root is created with an explicit `set_permissions(0o700)`, not the umask. Extracted text is stored as a content-addressed blob, recorded in the journal by `attachment.text_extracted`, and archived with the books. Page rasters are disposable. Deleted assumptions: `books_dir = data_dir.parent()` (S:1155, `ledger-gui/src/books.rs:1474`, `ledger-mcp/src/main.rs:102`), the `.gitignore` check (S:531-543), and the `$XDG_RUNTIME_DIR` viewer file (`ledger-store/src/viewer.rs:98-130`), which `ops/show.jsonl` replaces.
|
||||
The server, the CLI and the GUI find the data root with one shared function (`ledger_store::root::Root::find`, built in P2 slice 2): `--root` if given, else the root `wanderbooks setup` recorded in `wanderbooks.location` beside the default, else the platform default under the account's home, taken from the password database on Unix, never `$HOME`. Finding it never reads the environment, the working directory, a project directory or a toml file; `$XDG_DATA_HOME` is honoured once, by `setup`, which records the result. The root and every folder made in it are created with an explicit 0700, not the umask, and a root that is a symlink, belongs to another user or that others can enter is refused (`setup` tightens one of ours). `setup` leaves a `.wanderbooks-root` marker, and a folder without one is never opened as a root nor, if it holds anything, made into one; every folder above the root must be one no other user can swap it in (owned by this account or the system, not writable by others unless sticky). Run again, `setup` keeps the root it recorded, and it never makes a second root or moves books while the current one holds any. Neither `setup` nor any refusal prints the root's location: nothing an agent is shown names it. Books are created and adopted one at a time, under `run/books.lock`. Windows (P11): `%LOCALAPPDATA%` from the known-folder API and an owner-only DACL; until then the Windows paths are read from the profile and carry no ACL. Extracted text is stored as a content-addressed blob, recorded in the journal by `attachment.text_extracted`, and archived with the books. Page rasters are disposable. Deleted assumptions: `books_dir = data_dir.parent()` (S:1155, `ledger-gui/src/books.rs:1474`, `ledger-mcp/src/main.rs:102`), the `.gitignore` check (S:531-543), and the `$XDG_RUNTIME_DIR` viewer file (`ledger-store/src/viewer.rs:98-130`), which `ops/show.jsonl` replaces.
|
||||
|
||||
### 3.5 Keys
|
||||
|
||||
@@ -560,9 +560,9 @@ The `WANDERBOOKS_TOML` template (`ledger-cli/src/main.rs:237-347`) becomes the e
|
||||
- `wanderbooks books adopt <data-dir> --id X [--toml FILE] [--config-json FILE]` runs as the human, with no sudo, through the admin socket. The server, which also runs as the human, does the copy:
|
||||
1. Takes the source flock, so it refuses while a stdio server holds it.
|
||||
2. Verifies the chain and records head H.
|
||||
3. Copies `journal.jsonl` and `blobs/`, re-hashes every blob, rebuilds the projection, and checks that the head equals H.
|
||||
3. Copies `journal.jsonl` and `blobs/` into a staging folder (a cut-off last line or a link among the blobs is refused), opens the copy (rebuilding the projection), checks that the head equals H, verifies it end to end and re-hashes every blob; only then is it renamed into `books/X/`, and on any failure the staging folder is removed and nothing appears. A source inside the root, or holding it, is refused.
|
||||
4. Imports settings from the given texts (preview printed, confirmation at the TTY) unless the journal already has `settings.set` events.
|
||||
5. Moves `vault/*.bin` into `books/X/vault/`, and the v1 key into `keys/legacy.key`.
|
||||
5. Copies the vault's records into `books/X/vault/`, and the v1 key into `keys/legacy.key` (0600). Copied, not moved (P2 slice 2): adoption never changes the source, so a refused or abandoned adoption leaves it exactly as it was; step 7 is what moves the old copies away.
|
||||
6. Seeds `archive.git` from the source's git history, filtered to the journal and blob paths, and records the old repository's last commit hash. The push destination is the owner's answer to Q-1.
|
||||
7. If the owner agrees (Q-3), moves the source data dir, its vault and the project folder's git history into `<root>/legacy/X-<date>/`. Nothing is deleted. If the owner says no, the source stays where it is, read-only, and Needs attention lists it as an exposed old copy.
|
||||
- `ledger-cli` `init`/`new` stop scaffolding and call `books create`.
|
||||
|
||||
Reference in new issue
Block a user