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-coreas pure functions over data (fn plan(...) -> Plan), keep I/O inwispctlthin. 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!. Usewispplace_ui:- results (listings,
--json, final URLs) →wispplace_ui::out/out_text(stdout, so--json | jqworks); - 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.cyanheadline→s::accent,pc.cyanURL→s::link,pc.green→s::ok,pc.yellow→s::warn,pc.red→s::danger.
- results (listings,
- Errors:
anyhow::Resultinwispctl; typed errors (thiserror-free enums are fine) inwispplace-core.mainprintsError: {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]incli-rs/Cargo.tomland 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 <sha> derives the site name pr-<sha7> 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 #
scripts/build-cli-binaries.shbuilds every target intobinaries/withSHA256SUMS, under the legacywisp-cli-*names that CI pipelines curl.- Copy the new checksums into
binaries/index.html,docs/src/content/docs/cli.mdandapps/main-app/public/editor/views/cli.tsx, then deploybinaries/asnekomimi.pet/wisp-cli-binarieswithout--spa/--directory(keeps its settings). bun cli-rs/scripts/npm-packages.tsstageswispctl(a node shim) and the@wispplace/wispctl-<os>-<cpu>binary packages intarget/npm/. Publish the platform packages first, thenwispctl.[package.metadata.binstall]inwispctl/Cargo.tomlpointscargo binstallat the samewisp-cli-binariesURLs. It only takes effect from crates.io:cargo binstall --gitwants aCargo.tomlat 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:
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, samekvtable, 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 atapp-password:<did>(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<did>/oauth_session:<did>entries;logoutremoves 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 withBlobNotFoundwhenever 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
--jsonoutput is clean.
Pull-request previews #
wispctl preview enable and disable (2.0.3+) write and delete the place.wisp.v2.wh
record preview-<repo> that wakes the preview bot, the same record the dashboard writes.
As a collaborator (--owner <did>) the record is preview-<repo>~<owner did> 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-<repo>.
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.