# 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: "*"`). ## Preview deploys `deploy --sha ` derives the site name `pr-` from a seven-character or full 40-character lowercase hexadecimal commit SHA. It conflicts with `--site` and is validated before authentication or upload. Preview URL routing still uses `--preview-host preview.wisp.place` and the owner's wisp claim; this flag does not establish a pull-request association for the comment bot. The flag is available in 2.0.2+ builds. ## 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/views/cli.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. ## Nix distribution The root `flake.nix` packages this Rust workspace, not the legacy TypeScript CLI. `nix build .#default` (also `.#wispctl`) and `nix run . -- --help` keep the existing entry points. The version comes from the workspace manifest. Cargo dependencies are vendored from `Cargo.lock`; changing the jacquard git revision also requires updating its `cargoLock.outputHashes` entry with the hash Nix reports. `build-wispctl.yml` evaluates every advertised system, builds and tests the local system, checks the installed executable, then publishes to Cachix only on main pushes. `build-wispctl-rs.yml` separately runs rustfmt, clippy and workspace tests. The Nix build uses thin LTO to reduce CI memory usage. It supports x86_64 Linux, aarch64 Linux and aarch64 macOS; the pinned nixpkgs no longer supports Intel macOS. Evaluate all systems before changing the supported list or nixpkgs pin: ```sh nix flake check --all-systems --no-build --no-update-lock-file nix build --no-update-lock-file .#default ./result/bin/wispctl --version ./result/bin/wispctl --help bun test --isolate scripts/wispctl-nix.test.ts ``` ## 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. ### Pull-request previews `wispctl preview enable` and `disable` (2.0.3+) write and delete the `place.wisp.v2.wh` record `preview-` that wakes the preview bot, the same record the dashboard writes. As a collaborator (`--owner `) the record is `preview-~` instead, so it cannot replace the hook for a same-named repo of the user's own; 2.0.3 still wrote those to `preview-`. The CLI asks for the webhooks permission set for this, but does not require it, so older sessions still deploy and only `preview` asks them to log in again. `wispctl preview deploy` (2.1.0+) is the whole deploy step of a preview workflow. It reads the repo, its owner and the commit from spindle's `TANGLED_*` variables and the app password from `WISP_APP_PASSWORD`, then takes the claim from that same webhook record, so the claim lives only there and the workflow is identical for every repo. Without the record it fails; without the secret in a pull-request pipeline (a fork) it skips.