The terminal client for Solian: a Charm-based TUI and a scriptable CLI over one authentication core. Termian signs in through Stargate, either with the RFC 8628 device flow or with a password/TOTP challenge, then keeps the session alive by rotating the refresh token.
Signed in, it browses the public timeline, reads and writes posts, replies,
reactions and bookmarks, and reads and sends chat messages. Signed out, the
public timeline and post details stay readable: press g on the login screen to
browse as a guest.
go install src.solsynth.dev/sosys/termian@latestOr build from a checkout (Go 1.26+):
go build -o termian .
# hosted server (separate module; Go 1.26.8+ and shared v0.2.6)
go build -o termian-server ./hosted/cmd/termian-serverThe client binary is self-contained. The hosted server lives in hosted/, has
its own go.mod, and is the only part that uses Wish and Stargate's generated
gRPC client, so go install and client builds never pull those dependencies.
termian # interactive client: device login, then the session shell
termian login # same thing, explicitlyThe device flow shows a short code and a verification URL. Approve it in a browser (the Flutter app or https://id.solian.app/auth/device), and Termian moves to the session shell.
| Command | What it does |
|---|---|
termian |
Opens the TUI (device login, or the session shell when credentials still work) |
termian login |
Sign in; --password selects the challenge flow instead of the device flow |
termian logout |
Revokes the session server-side and deletes local credentials |
termian whoami |
Prints the account and session |
termian refresh |
Rotates the access token and prints the new expiry |
termian feed |
Lists the ranked timeline (--mode, --cursor); works with or without a session |
termian post <id> |
Prints a post and its replies |
termian publish |
Publishes a status, article or blog link (--content, --file -, --type, --title, --description, --publisher, --tag, --attach, --visibility, --language, --slug) |
termian reply <id> |
Replies to a post |
termian react <id> |
Toggles a reaction (--symbol, default heart) |
termian save <id> |
Bookmarks a post; --remove drops the bookmark |
termian bookmarks |
Lists saved posts |
termian publishers |
Lists the publishers you can post as |
termian chats |
Lists chat rooms with unread counts |
termian chat <room-id> |
Prints a room's messages; --send --content … posts one |
termian drive ls [folder] |
Lists files and folders in Drive (--take, --offset) |
termian drive upload <file> |
Uploads a local file to Drive (--folder, --name) |
termian drive download <id> |
Downloads a file from Drive (--out) |
termian drive mkdir <name> |
Creates a new folder in Drive (--folder) |
termian drive rm <id> |
Moves a file to recycle bin (--permanent to purge) |
termian drive quota |
Shows storage quota and usage breakdown |
termian mail mailboxes |
Lists your ElecPostal mailboxes |
termian mail list |
Lists email conversation threads (--folder, --mailbox, --take) |
termian mail read <id> |
Reads a thread conversation or single email |
termian mail send |
Sends an email (--to, --subject, --body, --mailbox, --cc) |
termian mail star <id> |
Stars or unstars an email (--remove) |
termian mail delete <id> |
Moves an email to trash (--permanent to purge) |
termian version |
Prints the build version |
feed and post read public data, so they work before signing in; feed
ranks for the account when a session is available. Everything else needs a
session and exits 3 without one.
Flow selection:
- The TUI starts when
--no-tuiis absent,--outputistext, and stdout is a terminal. Every other combination runs headless, sotermian --output jsonis a fully scriptable login. --device(default) uses the device flow;--passworduses the challenge flow and prompts for the account, the factor and the secret on stdin. Without a TTY the secret is read as a plain line, which keeps CI/scripted use possible.
Persistent flags:
| Flag | Default | Meaning |
|---|---|---|
--server |
https://api.solian.app |
Stargate API base; /stargate is appended when the URL has no path |
--client-id |
solian |
OAuth client used by the device flow |
--output |
text |
text or json |
--store |
auto |
auto, keyring or file |
--config |
XDG config path | Explicit config.toml |
--device-name |
hostname | Device name reported to the server |
--timeout |
15s |
Per-request timeout |
--verbose |
off | Extra diagnostics on stderr |
--no-tui |
off | Never start the interactive UI |
--device / --password |
--device |
Login flow |
--attachments |
browser |
browser, preview or off |
--chat-history |
200 |
Messages kept per chat room |
With --output json, every record is one compact line on stdout, and errors are
one line on stderr. Each record carries type:
| Command | Records |
|---|---|
login (device) |
{"type":"authorization","user_code","verification_uri","verification_uri_complete","expires_at","interval_seconds","server"}, then {"type":"authenticated",...} once approved |
login --password |
{"type":"authenticated","account","session","server","storage","method","access_expires_at"} |
whoami |
{"type":"account","account","session","server","storage"} |
refresh |
{"type":"refreshed","access_expires_at","refresh_expires_at","server","storage"} |
logout |
{"type":"logged_out","server","revoked"} |
feed |
{"type":"post","post":{...}} per post, then {"type":"timeline","mode","next_cursor"} |
bookmarks |
{"type":"post","post":{...}} per post |
post |
{"type":"post",...} then {"type":"reply","post","reply"} per reply |
publish, reply |
{"type":"created","post":{...}} |
react |
{"type":"reaction","post","symbol","is_present"} |
save |
{"type":"bookmark","post","is_bookmarked"} |
publishers |
{"type":"publisher","id","name","nick","kind"} |
chats |
{"type":"chat_room","room":{...}} per room |
chat |
{"type":"chat_message","message":{...}} oldest first |
chat --send |
{"type":"chat_sent","message":{...}} |
drive ls |
{"type":"drive_file","file":{...}} per item |
drive upload |
{"type":"drive_file","file":{...}} |
drive download |
{"type":"drive_download","file_id","path","size","file_name"} |
drive mkdir |
{"type":"drive_file","file":{...}} |
drive quota |
{"type":"drive_quota","usage":{...}} |
mail mailboxes |
{"type":"mailbox","mailbox":{...}} per mailbox |
mail list |
{"type":"mail_thread","thread":{...}} per conversation |
mail read |
{"type":"mail_email","email":{...}} per email in thread |
mail send |
{"type":"mail_sent","email":{...}} |
| any failure | {"type":"error","code","message","status","hint"} on stderr |
react follows the server's toggle semantics: posting a symbol that the account
already reacted with removes the reaction, and is_present reports the state
after the call.
Exit codes:
| Code | Meaning |
|---|---|
| 0 | success |
| 1 | other API failure (e.g. AUTH_ACCOUNT_NOT_FOUND, AUTH_INVALID_PASSWORD) |
| 2 | usage error |
| 3 | not signed in, or the session expired |
| 4 | transport failure (dial, timeout, TLS) |
| 5 | server error or malformed response |
| 6 | rate limited |
| 7 | authorization denied or device code expired |
Termian never prints tokens, in either output mode: access tokens live in memory, and the refresh token only exists in the keyring or the credential file.
$XDG_CONFIG_HOME/termian/config.toml, all keys optional:
server = "https://api.solian.app"
client_id = "solian"
scope = "*"
store = "auto" # auto | keyring | file
output = "text"
attachments = "browser" # browser | preview | off
chat_history = 200 # messages kept per chat roomPrecedence: flag > environment > file > defaults.
| Variable | Overrides |
|---|---|
TERMIAN_SERVER |
server |
TERMIAN_CLIENT_ID |
client_id |
TERMIAN_OUTPUT |
output |
TERMIAN_STORE |
store |
TERMIAN_ATTACHMENTS |
attachments |
TERMIAN_CHAT_HISTORY |
chat_history |
TERMIAN_CONFIG |
config file location |
A missing config file is fine; a malformed one is a hard error naming the path.
- The refresh token goes to the system keyring (Secret Service on Linux, Keychain
on macOS, Credential Manager on Windows) under the service name
termian, keyed by the SHA-256 of the normalized server URL. - When no keyring is available,
--store autofalls back to$XDG_STATE_HOME/termian/credentials-<hash>.json, written atomically with mode0600inside a0700directory, and prints a one-line notice (text mode only).--store keyringrefuses to fall back;--store fileskips the keyring. - Access tokens are never persisted. Hosted sessions keep both tokens in
per-connection process memory (
credentials.NewMemory) and write nothing to disk or a shared keyring. - The device identity is a SHA-256 of
/etc/machine-idwhen present, otherwise a random id persisted at$XDG_STATE_HOME/termian/device-id(0600). A stable identity matters: Stargate keys challenge reuse and risky-IP accounting on it.
The device flow needs a pre-registered OAuth client. Termian defaults to the
public solian client, whose allow-list is *; override with --client-id if you
register your own. The requested scope defaults to * and is configurable through
scope in config.toml, because openid, profile and email are rejected as
invalid_scope for that client.
termian-server runs the same TUI as an SSH application, one Bubble Tea program
per connection. A user connects with any SSH client; the server validates the
offered key against Stargate and exchanges it for a Solian session, so there is
no device code or password step.
go build -o termian-server ./hosted/cmd/termian-server
./termian-server \
--listen :2222 \
--stargate-grpc 127.0.0.1:9090 \
--exchange-token "$TERMIAN_EXCHANGE_TOKEN"The server is a separate module. It builds in this checkout through
hosted/go.work, which points at ../LightHouse/shared; container and CI
builds use GOWORK=off and fetch the published shared/v0.2.6 module.
Stargate must enable the exchange with the same secret:
[hosted]
exchangeToken = "…"The user's public key must be registered on their Solian account through
POST /api/keys/ssh (Settings → SSH keys in the console). Keys are public
material, so the exchange RPC is closed unless the deployment configured a
token; the SSH handshake is what proves possession.
Flow:
- The SSH server asks Stargate's
DyAccountKeyServicetoExchangeSSHPublicKeyForSessionwith the offered key, the service token and a purpose (defaulttermian-hosted). - Stargate resolves the key to its account, refuses locked accounts, creates an OIDC session and returns access and refresh tokens.
- The TUI starts on the session shell with those tokens; the refresh token
stays in per-session memory and rotates through
/auth/tokenlike a challenge login.
Hosted flags:
| Flag | Default | Meaning |
|---|---|---|
--listen |
:2222 |
SSH listen address |
--stargate-grpc |
127.0.0.1:9090 |
Stargate's internal gRPC address |
--stargate-grpc-tls |
off | TLS to Stargate |
--stargate-grpc-ca |
empty | CA bundle verifying Stargate; empty skips verification |
--stargate-grpc-cert |
empty | client certificate for a Stargate enforcing mTLS |
--stargate-grpc-key |
empty | client key paired with --stargate-grpc-cert |
--stargate-grpc-server-name |
empty | verified certificate name; defaults to the dial host |
--exchange-token |
$TERMIAN_EXCHANGE_TOKEN |
Hosted exchange secret; required |
--purpose |
termian-hosted |
Purpose echoed in Stargate's audit log |
--host-key |
$XDG_STATE_HOME/termian/host_ed25519 |
SSH host key; generated when missing |
Fleet deployments enable --stargate-grpc-tls and present the shared client
certificate; Stargate's gRPC listener enforces mTLS when clientCAFile is set.
--server, --client-id, --attachments, --chat-history and --timeout
apply to hosted sessions too. The attachment policy defaults to preview
because the server has no display for browser.
Dockerfile defines two targets:
| Target | Binary | Use |
|---|---|---|
server (default) |
termian-server |
Hosted fleet image; exposes 2222 |
client |
termian |
Client-only TUI image, no SSH server |
docker build --target server -t termian-server .
docker build --target client -t termian-client .
docker run --rm -p 2222:2222 \
-e TERMIAN_EXCHANGE_TOKEN \
-v termian-host-key:/data \
termian-server --stargate-grpc stargate:9090 --stargate-grpc-tls
docker run --rm -it -v termian-client-state:/data termian-clientBoth runtime images are scratch with CA certificates and XDG_STATE_HOME=/data
so credential and host-key files survive through the volume. The server image
needs shared/v0.2.6 published before it can compile; the client image does not.
main.go entry point
internal/api Stargate client: device flow, challenge flow, sessions
internal/api/content.go sphere client: timeline, posts, edits, replies, reactions, bookmarks
internal/api/chat.go messager client: rooms, messages, edits, reactions
internal/api/tokensource.go access-token rotation, single-flight, session expiry
internal/credentials keyring store with 0600 file fallback and a per-session memory store
internal/deviceid stable device identity
internal/config config file + env + flags
internal/ui text/NDJSON renderer and exit codes
internal/cli cobra commands and the headless flows
hosted/ separate module: wish SSH server and command
internal/tui Bubble Tea app: login, session shell, feed, post, chats
internal/tui/attach.go attachment listing, browser hand-off and image previews
The TUI talks to two interfaces (internal/tui/app.go): AuthAPI for the login
panes and ContentAPI for the feeds and chats. *api.Client satisfies both, so
the screens are testable without a server.
| Pane | Keys |
|---|---|
| Login | g browse as guest, p password login, c copy code, o open browser, esc quit |
| Session shell / Profile | j/k select a publisher or notification topic, enter apply, r refresh, f timeline, b bookmarks, c chats, n new post, l logout |
| Notifications | j/k select, enter open the post or link, a mark all read, d delete, R reload, esc back |
| Any content pane | tab next section, shift+tab previous section |
| Feed | j/k move, G last, enter open, r react, b bookmark, t timeline/bookmarks, c post, e edit, d delete, R reload, esc back |
| Post | j/k move, enter open a reply, r react, b bookmark, c reply, e edit, d delete, o open attachment, p preview, R reload, esc back |
| Chats | j/k move, enter step into the conversation, i write, R reload, tab sections, esc back |
| Chat room | j/k move, k at the top pages in older history, g/G jump to either end, r react, i write, o open attachment, h back to the room list |
| Composer | tab/shift+tab fields, ctrl+d publish, ctrl+p publisher, ctrl+t status/article/blog, ctrl+v visibility, ctrl+o load local markdown as an article, ctrl+a attach a file, alt+enter newline, esc cancel |
| Confirmation | y confirm, n/esc cancel |
Write keys are refused locally when browsing as a guest: the status line asks for a sign-in instead of failing at the server. Deleting a post asks for confirmation first.
attachments decides what Termian does with a post's files:
| Mode | Behaviour |
|---|---|
browser (default) |
Lists name, type and size; o hands the file to the system opener |
preview |
Also renders the first image inline in the post pane; p re-downloads it |
off |
Hides attachments entirely |
Previews download at most 8 MiB per file and render into 40×10 cells: half blocks in true color terminals, a luminance ASCII ramp otherwise. Rendered previews are cached for the session.
The browsing panes share one workspace, switched with tab: timeline,
chats, bookmarks. The pane header names the active list.
The signed-in shell also exposes notifications (3) and a profile/settings
page (4). Notifications mirror Ring: topic, title, body, action link and an
unread dot, with a to mark everything read and d to delete. enter follows
the action link: post links open the post pane, everything else goes to the
system browser.
The profile page shows the account id, role, perks, locale, activation date,
level/XP, publishers, notification topics and the live session card. enter on
a publisher makes it the composer default; enter on a notification topic
cycles normal → silent → reject and writes the preference to Ring.
Chat is an IRC-style workspace in three columns: rooms on the left, the
transcript in the middle as time <nick> text lines, and the room's nicklist on
the right. The active column is labelled rooms ◂ or conversation ◂ and the
header names the focus, so j/k scope is explicit. Rooms with unread messages
carry a [*] marker, and members show ● (online), ◐ (idle) or ○
(offline). The nicklist is hidden on narrow terminals and never shows the
signed-in account.
Chat scrollback pages backwards through the room history 50 messages at a time
and keeps at most chat_history messages per room. System events (call presence,
attachment finalisation, reaction receipts) are filtered out of the transcript,
so the offset tracks rows read from the server rather than visible messages.
When a session exists the TUI opens the Solian websocket gateway
(/ws?namespace=dev.solsynth.solian) with the bearer token. notifications.new
and messages.new frames update the badge, the notification list and the open
chat immediately; each frame with an _ack_id is acknowledged, new connections
request session.resume, and the client reconnects with backoff. The footer
shows ● live while the socket is open.
The composer supports three post types: status, article and blog link. ctrl+o
loads a local Markdown file as an article and fills the title from the first
heading (and the description from the first paragraph); ctrl+a uploads a local
file to Drive and attaches it (20 MiB direct-upload limit); ctrl+p cycles
publishers; ctrl+t cycles the type; ctrl+v cycles visibility. Titles,
descriptions and comma-separated tags are part of the publish and edit payloads.
Blog links must be absolute http(s) URLs.
go build ./... && go vet ./... && go test ./...
cd hosted && go build ./... && go vet ./... && go test ./...The test suite is offline: HTTP is served by httptest, the keyring by
keyring.MockInit*, and the TUI is driven by teatest.