Monorepo for wisp.place. A static site hosting service built on top of the AT Protocol.
8.9 kB
Markdown
at main

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 <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 #

  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-<os>-<cpu> 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:

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:<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; 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-<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.