# wispctl (Rust) A from-scratch Rust port of the TypeScript `cli/` (wispctl 1.3.x). The TS source is the contract: same commands, flags, defaults, prompts, wording, on-disk state and network behaviour, unless this file says otherwise. ## Layout | crate | what | effects | |---|---|---| | `wispplace-lexicons` | `place.wisp.*` types generated by `jacquard-codegen` (`scripts/lexgen.sh`). Never hand-edit. | none | | `wispplace-core` | Pure logic: CID/gzip/MIME rules, ignore rules, tree building, subfs split + expansion, path validation, `_redirects`, generated pages, OAuth scope maths, constants. | none (callbacks for fetches) | | `wispplace-ui` | All terminal output: prompts, spinners, progress, panels, printed lines. ratatui inline viewport. | terminal | | `wispctl` | clap CLI, auth + account store, XRPC, commands. | everything | ## Conventions - **Functional core, imperative shell.** Put decisions in `wispplace-core` as pure functions over data (`fn plan(...) -> Plan`), keep I/O in `wispctl` thin. Prefer immutable values, iterators and small enums over mutable state and flags. Traits only where an effect needs a fake in tests. - **Never `println!`/`eprintln!`.** Use `wispplace_ui`: - results (listings, `--json`, final URLs) → `wispplace_ui::out` / `out_text` (stdout, so `--json | jq` works); - status → `note`, `success`, `failure`, `warning`, `info`, `blank`; - work in flight → `spinner(..)` then `.succeed(..)`/`.fail(..)`; counted work → `progress(label, total, Direction::Up|Down)`; - long-lived dashboards → `panel(..)`; - prompts → `crate::prompts::*` (handles cancel → exit 0, and the no-TTY case with a hint about which flag to pass). - colour mapping from the TS code: `pc.bold`→`s::bold`, `pc.dim`→`s::muted`, `pc.cyan` headline→`s::accent`, `pc.cyan` URL→`s::link`, `pc.green`→`s::ok`, `pc.yellow`→`s::warn`, `pc.red`→`s::danger`. - Errors: `anyhow::Result` in `wispctl`; typed errors (`thiserror`-free enums are fine) in `wispplace-core`. `main` prints `Error: {err:#}` and exits 1. - Keep the TS wording for user-facing messages; agents and scripts grep them. - Tests next to the code (`#[cfg(test)]`). Port the TS tests (`cli/**/*.test.ts`) and add fixtures generated from the TS implementation wherever byte-compatibility matters (CIDs, gzip, JSON shapes). - Latest crate versions; add deps to `[workspace.dependencies]` in `cli-rs/Cargo.toml` and reference them with `.workspace = true`. Binary size matters: `default-features = false`, rustls only, no second async runtime, no second HTTP stack if jacquard's reqwest already covers it. ## jacquard We build against ana's fork, `https://tangled.org/okami.mom/jacquard` `main` (upstream `main`, 0.13.0 unreleased, plus fixes pending upstream: repo scope `collection` params, permission-set rpc audiences per spec, and always writing `aud` on rpc scopes). `Cargo.lock` pins the commit; `cargo update -p jacquard` moves it. `scripts/lexgen.sh` runs the fork's `jacquard-codegen` from a local checkout (`JACQUARD_DIR`, default `../../jacquard`); unlike 0.12 it also accepts the permission-set lexicons (`aud: "*"`). ## Releasing 1. `scripts/build-cli-binaries.sh` builds every target into `binaries/` with `SHA256SUMS`, under the legacy `wisp-cli-*` names that CI pipelines curl. 2. Copy the new checksums into `binaries/index.html`, `docs/src/content/docs/cli.md` and `apps/main-app/public/editor/tabs/CLITab.tsx`, then deploy `binaries/` as `nekomimi.pet/wisp-cli-binaries` without `--spa`/`--directory` (keeps its settings). 3. `bun cli-rs/scripts/npm-packages.ts` stages `wispctl` (a node shim) and the `@wispplace/wispctl--` binary packages in `target/npm/`. Publish the platform packages first, then `wispctl`. 4. `[package.metadata.binstall]` in `wispctl/Cargo.toml` points `cargo binstall` at the same `wisp-cli-binaries` URLs. It only takes effect from crates.io: `cargo binstall --git` wants a `Cargo.toml` at the repository root. crates.io waits for a jacquard release carrying the fork's fixes (git dependencies cannot be published); the crates are already named `wispplace-*` because `wisp-core` and `wisp-ui` are taken there. ## Compatibility decisions - State DB stays at `~/.config/wispctl/state.sqlite`, same `kv` table, same keys (`account:`, `handle:`, `dir:`, `default_account`, `store_version`, `dir_oauth_port:`), same JSON account shape, 0600/0700 permissions. Existing installs keep their accounts, directory links and default account. - Keychain service `wispctl`. App passwords stay at `app-password:` (shared with the TS CLI). jacquard OAuth sessions use their own namespace (`oauth-v2:...` keychain accounts, `oauth_session_v2:` / `oauth_state_v2:` kv fallback keys) and never read or overwrite the TS CLI's `` / `oauth_session:` entries; `logout` removes both old and new entries. - Blob bytes must match what the TS CLI uploads (gzip level 9, Node zlib header) so unchanged files are reused across both CLIs. ## Deliberate differences from the TypeScript CLI - Records are written through the generated types (`Fs`, `SubfsRecord`, `Settings`) and jacquard's typed record calls, not hand-built JSON. - Subfs record keys carry a per-deploy TID (`{site}-{tid}-subfs-1`, `{site}-{tid}-chunk-0`). Reusing the previous deploy's keys overwrote records that still referenced reused blobs; the PDS deletes unreferenced blobs immediately, so the next write failed with `BlobNotFound` whenever the chunk layout changed. Old records are deleted after the new manifest lands. - When a directory is too large for one subfs record, splitting descends into an oversized subdirectory instead of chunking the parent's entries. The TS planner put a 600-file nested folder into one 180 KB chunk with a 600-entry directory, which the lexicon's 500-entry limit rejects. - Uploads use a replenishing worker pool instead of fixed batches; file order is sorted and deterministic. - New gzip blobs always use OS byte 3; reuse matches the OS bytes Node/Bun write on any platform, so blobs from either CLI are reused. - Directory listings sort case-insensitively instead of with ICU collation. - Status goes to stderr and results to stdout, so `--json` output is clean.