# MNW CLI — SSH Terminal Interface ## Overview A TUI application served over SSH at `ssh cli.makenot.work`. Creators connect from any terminal and get a full-screen interactive interface for managing their MNW projects, items, uploads, analytics, and monetization — no browser needed. Inspired by `ssh terminal.shop` (Charm/Go). Built in Rust with russh + ratatui. ## Why The web dashboard is thorough but slow for repeated I/O. Creators who publish frequently (musicians uploading tracks, developers shipping versions, writers posting articles) benefit from a fast keyboard-driven workflow. The CLI collapses multi-step wizard flows into single commands and makes bulk operations natural. SSH as transport means zero client installation — every machine already has an SSH client. ## Architecture ``` ┌──────────────┐ SSH (port 2222) ┌──────────────────────┐ │ Creator's │ ◄─────────────────────► │ mnw-cli binary │ │ Terminal │ (russh server) │ (separate process) │ └──────────────┘ │ │ │ ┌────────────────┐ │ │ │ ratatui TUI │ │ │ │ (per-session) │ │ │ └────────┬───────┘ │ │ │ │ │ ┌────────▼───────┐ │ │ │ HTTP client │ │ │ │ → MNW API │ │ │ └────────┬───────┘ │ └───────────┼──────────┘ │ ┌───────────▼──────────┐ │ MNW server │ │ (Axum, port 3000) │ │ PostgreSQL │ │ S3 │ └──────────────────────┘ ``` ### Separate binary, not embedded in MNW - `mnw-cli` is its own Rust binary with its own Cargo project - Talks to MNW via its existing JSON API (same endpoints the dashboard uses) - Runs on the same Hetzner VPS, connects to MNW at localhost:3000 - Caddy proxies SSH on port 22 or the binary listens on 2222 (Caddy can't proxy SSH — see Deployment) ### Why API client, not direct DB access - MNW's API already handles auth, validation, storage tracking, Stripe, email notifications - No risk of bypassing business logic (tier limits, malware scanning, etc.) - CLI stays thin — a TUI skin over the API - Can run on a different machine if needed ## Tech Stack | Component | Crate | Role | |-----------|-------|------| | SSH server | `russh` (0.58+) | Accept connections, authenticate, manage sessions | | TUI framework | `ratatui` (0.30+) | Render UI into SSH channel | | Terminal backend | `crossterm` | Crossterm backend with custom Write impl | | HTTP client | `reqwest` | Call MNW API endpoints | | Async runtime | `tokio` | russh and reqwest both need it | | Key parsing | `ssh-key` (re-exported by russh) | Parse/fingerprint public keys | | S3 uploads | `reqwest` | PUT to presigned URLs | No new dependencies on MNW's side. The CLI is a pure API consumer. ## Authentication MNW already stores SSH public keys per user (`user_ssh_keys` table, managed via `/api/users/me/ssh-keys`). Creators register keys for git access to `ssh.makenot.work`. The CLI reuses this. ### Flow 1. Creator runs `ssh cli.makenot.work` 2. russh server receives connection, calls `auth_publickey_offered` with the offered key 3. Handler queries MNW API: `GET /api/internal/ssh-key-lookup?fingerprint={sha256}` (new internal endpoint) 4. If key found → `Auth::Accept`, russh verifies signature, then `auth_publickey` fires 5. Handler stores authenticated user info (user_id, username, display_name, creator_tier) 6. TUI launches with full creator context 7. All subsequent API calls use an internal service token + user_id header (no session cookie needed) ### New MNW endpoint needed ``` GET /api/internal/ssh-key-lookup?fingerprint={sha256_fingerprint} → 200 { user_id, username, display_name, creator_tier, can_create_projects } → 404 (key not found) ``` Internal-only (localhost or shared secret). Not exposed publicly. ### No key? Helpful rejection If the SSH key isn't registered, the CLI sends a message before disconnecting: ``` Your SSH key is not linked to a Makenot.work account. To connect: 1. Log in at makenot.work/dashboard 2. Go to Settings → SSH Keys 3. Add your public key (~/.ssh/id_ed25519.pub) Then try again: ssh cli.makenot.work ``` ## Screens ### Home (after auth) ``` ┌─ Makenot.work ── max ── Small Files tier ── 142MB / 10GB ─────────┐ │ │ │ Projects │ │ ┌─────────────────────────────────────────────────────────────┐ │ │ │ ▸ ambient-textures 12 items $847 revenue 3 drafts │ │ │ │ sound-tools 4 items $203 revenue 0 drafts │ │ │ │ field-recordings 8 items $56 revenue 1 draft │ │ │ └─────────────────────────────────────────────────────────────┘ │ │ │ │ Quick Stats (last 30 days) │ │ Revenue: $312 Sales: 47 Followers: 89 Views: 1,204 │ │ │ │ [n] New project [u] Upload [b] Blog post [a] Analytics │ │ [p] Promo codes [e] Export [s] Settings [q] Quit │ └─────────────────────────────────────────────────────────────────────┘ ``` ### Project View ``` ┌─ ambient-textures ── Music ── 12 items ── $847 total ──────────────┐ │ │ │ Items Filter: [All ▾] │ │ ┌──────────────────────────────────────────────────────────────┐ │ │ │ ● dawn-chorus-vol-2 Audio $12.00 Published Mar 25 │ │ │ │ ● rain-on-glass Audio $8.00 Published Mar 20 │ │ │ │ ○ city-at-night Audio $0.00 Draft Mar 18 │ │ │ │ ● texture-pack-spring Bundle $24.00 Published Mar 15 │ │ │ │ ... │ │ │ └──────────────────────────────────────────────────────────────┘ │ │ │ │ [u] Upload new [n] New item [b] Blog [p] Promo [t] Tiers │ │ [Enter] Open item [d] Delete [Space] Select [P] Bulk publish │ │ [Esc] Back │ └─────────────────────────────────────────────────────────────────────┘ ``` ### Upload Flow (the killer feature) ``` ┌─ Upload to: ambient-textures ───────────────────────────────────────┐ │ │ │ Drop files or enter paths: │ │ > ~/Music/exports/dawn-chorus-v2.wav │ │ │ │ Queue: │ │ ┌──────────────────────────────────────────────────────────────┐ │ │ │ dawn-chorus-v2.wav Audio [dawn chorus v2 ] ██████ ✓ │ │ │ │ rain-textures.flac Audio [rain textures ] ████░░ 67%│ │ │ │ field-kit-march.zip Digital[field kit march ] ░░░░░░ Q │ │ │ └──────────────────────────────────────────────────────────────┘ │ │ │ │ Title auto-filled from filename. Tab to edit before upload. │ │ │ │ Price: [$8.00 ] PWYW: [off] Publish: [immediately] │ │ │ │ [Enter] Start upload [Tab] Edit field [Esc] Cancel │ └─────────────────────────────────────────────────────────────────────┘ ``` Upload pipeline per file (same as web): 1. Create item → `POST /api/projects/{pid}/items` (form: title, item_type, price_cents) 2. For audio: `POST /api/upload/presign` → PUT to S3 → `POST /api/upload/confirm` 3. For other: `POST /api/items/{id}/versions` → `POST /api/versions/{vid}/upload/presign` → PUT to S3 → confirm 4. Publish if requested **Path input**: The TUI reads local file paths from stdin. The file bytes are read locally and uploaded directly to S3 via the presigned URL. The SSH channel carries the TUI frames + user input, not the file data — files go straight to S3 from the creator's machine. Wait — that's wrong. The SSH session runs on the server. The creator's local files aren't accessible. ### File Upload: The SSH Problem SSH TUI apps run server-side. The creator's local files aren't accessible from the server process. Solutions: **Option A: SCP/SFTP sidecar** (recommended) - The SSH server also handles SCP/SFTP subsystem requests - Creator uploads files first: `scp track.wav cli.makenot.work:upload/` - Files land in a per-user staging directory on the server - TUI shows staged files, creator assigns titles/types/prices, then publishes - Staging dir cleaned after publish or on timeout ```bash # Upload files scp *.wav cli.makenot.work:upload/ # Then connect to manage them ssh cli.makenot.work ``` Or in one flow with SSH command mode: ```bash # Upload + auto-publish scp track.wav cli.makenot.work:upload/ambient-textures/ ssh cli.makenot.work publish ambient-textures # publishes all staged files ``` **Option B: Pipe mode** ```bash # Pipe a file directly cat track.wav | ssh cli.makenot.work upload --project ambient-textures --title "Dawn Chorus" --type audio ``` **Option C: Local CLI client** (complementary, not primary) A thin local binary that handles file reading and calls the API directly. But this requires installation — loses the zero-install SSH advantage. Better as a future add-on. **Recommendation**: Option A (SCP staging) as primary. Option B (pipe) as shortcut for single files. ### Upload Screen (with staging) ``` ┌─ Upload to: ambient-textures ───────────────────────────────────────┐ │ │ │ Staged files (scp'd to cli.makenot.work:upload/) │ │ ┌──────────────────────────────────────────────────────────────┐ │ │ │ ✓ dawn-chorus-v2.wav 43MB Audio [dawn chorus v2 ] │ │ │ │ ✓ rain-textures.flac 67MB Audio [rain textures ] │ │ │ │ ✓ field-kit-march.zip 12MB Digital[field kit march ] │ │ │ └──────────────────────────────────────────────────────────────┘ │ │ │ │ Defaults for all: │ │ Price: [$8.00 ] PWYW: [off] Publish: [immediately] │ │ │ │ [Enter] Upload + publish all [Tab] Edit per-file [Esc] Cancel │ └─────────────────────────────────────────────────────────────────────┘ ``` ### Item Editor ``` ┌─ dawn-chorus-vol-2 ── Audio ── $12.00 ── Published ────────────────┐ │ │ │ Title: [dawn chorus vol 2 ] │ │ Description: [Layered field recordings from sunrise sessions ] │ │ [in Olympic National Park. 24-bit WAV. ] │ │ Price: [$12.00 ] PWYW: [on ] Min: [$5.00] │ │ Tags: ambient, field-recording, nature │ │ Status: Published (Mar 25, 2026) │ │ File: dawn-chorus-v2.wav (43MB, v1.0) │ │ Downloads: 127 │ │ │ │ Versions: │ │ ┌──────────────────────────────────────────────────────────┐ │ │ │ v1.0 dawn-chorus-v2.wav 43MB Mar 25 │ │ │ │ v0.9 dawn-chorus-v2-beta.wav 41MB Mar 20 │ │ │ └──────────────────────────────────────────────────────────┘ │ │ │ │ [e] Edit fields [v] New version [u] Unpublish [d] Delete │ │ [c] Chapters [t] Tags [Esc] Back │ └─────────────────────────────────────────────────────────────────────┘ ``` ### Analytics ``` ┌─ Analytics ── Last 30 days ──────────────────────────────────────────┐ │ │ │ Revenue Sales Followers Views │ │ $312.00 47 89 (+12) 1,204 │ │ │ │ Revenue by day: │ │ $40 │ ╷ │ │ $30 │ ╷ │ ╷ │ │ $20 │ ╷ │ ╷ │ │ ╷ │ │ $10 │ │ │ │ │╷ │ │ ╷ ╷ ╷ │ │ $0 └─┴─┴─┴─┴┴─┴─┴─┴──┴─────┴──────────────────── │ │ 1 3 5 7 9 11 13 15 17 19 21 23 25 27 │ │ │ │ Top items: │ │ 1. dawn-chorus-vol-2 $144 (12 sales) │ │ 2. texture-pack-spring $96 (4 sales) │ │ 3. rain-on-glass $72 (9 sales) │ │ │ │ [w] Week [m] Month [y] Year [e] Export CSV [Esc] Back │ └──────────────────────────────────────────────────────────────────────┘ ``` ### Blog Post Editor ``` ┌─ New Blog Post ── ambient-textures ──────────────────────────────────┐ │ │ │ Title: [March field recording roundup ] │ │ Slug: [march-field-recording-roundup ] │ │ │ │ ┌── Markdown ──────────────────────────────────────────────────┐ │ │ │ ## What I recorded this month │ │ │ │ │ │ │ │ Three new locations in the Pacific Northwest: │ │ │ │ │ │ │ │ 1. **Hoh Rainforest** — moss-dampened bird calls at dawn │ │ │ │ 2. **Ruby Beach** — wave textures on cobblestone │ │ │ │ 3. **Hurricane Ridge** — wind through subalpine fir │ │ │ │ │ │ │ │ Each recording is 20+ minutes of unprocessed audio. │ │ │ │ ~ │ │ │ └──────────────────────────────────────────────────────────────┘ │ │ │ │ Publish: [now ▾] Email followers: [yes] Web only: [no] │ │ │ │ [Ctrl+S] Save draft [Ctrl+P] Publish [Esc] Cancel │ └──────────────────────────────────────────────────────────────────────┘ ``` ### Promo Codes ``` ┌─ Promo Codes ── ambient-textures ────────────────────────────────────┐ │ │ │ Active codes: │ │ ┌──────────────────────────────────────────────────────────────┐ │ │ │ SPRING25 25% off All items 47 uses No limit │ │ │ │ FREESAMPLE Free rain-on-glass 12 uses 50 max │ │ │ │ BETATESTERS $5 off All items 3 uses 10 max │ │ │ └──────────────────────────────────────────────────────────────┘ │ │ │ │ [n] New code [d] Delete [Esc] Back │ └──────────────────────────────────────────────────────────────────────┘ ``` ### License Keys (software creators) ``` ┌─ License Keys ── sound-tools ── audio-splitter-pro ──────────────────┐ │ │ │ Settings: Max activations per key: 3 │ │ │ │ Keys: │ │ ┌──────────────────────────────────────────────────────────────┐ │ │ │ ABCD-1234-EFGH Active 2/3 activations Mar 25 │ │ │ │ IJKL-5678-MNOP Active 1/3 activations Mar 20 │ │ │ │ QRST-9012-UVWX Revoked 0/3 activations Mar 15 │ │ │ └──────────────────────────────────────────────────────────────┘ │ │ │ │ [g] Generate keys [r] Revoke [Esc] Back │ └──────────────────────────────────────────────────────────────────────┘ ``` ### Settings ``` ┌─ Settings ───────────────────────────────────────────────────────────┐ │ │ │ Account │ │ Username: max │ │ Display name: [Max J ] │ │ Email: max@example.com (verified) │ │ Tier: Small Files ($24/mo) │ │ Storage: 142MB / 10GB ██░░░░░░░░ 1.4% │ │ │ │ Security │ │ 2FA: TOTP enabled │ │ SSH Keys: 2 registered │ │ Sessions: 3 active │ │ │ │ SSH Keys: │ │ ┌──────────────────────────────────────────────────────────────┐ │ │ │ laptop ssh-ed25519 SHA256:abc... Added Mar 10 │ │ │ │ desktop ssh-ed25519 SHA256:def... Added Feb 28 │ │ │ └──────────────────────────────────────────────────────────────┘ │ │ │ │ [e] Edit profile [k] Manage keys [x] Export data [Esc] Back │ └──────────────────────────────────────────────────────────────────────┘ ``` ## SSH Command Mode Beyond the interactive TUI, support direct commands via SSH: ```bash # Quick publish staged files ssh cli.makenot.work publish ambient-textures # List projects ssh cli.makenot.work projects # View analytics ssh cli.makenot.work analytics --last 30d # Generate promo code ssh cli.makenot.work promo ambient-textures --code SPRING25 --discount 25% # Upload and publish in one shot (pipe mode) cat track.wav | ssh cli.makenot.work upload ambient-textures --title "New Track" --type audio --price 800 # Export sales data ssh cli.makenot.work export sales --format csv > sales.csv # Blog post from file cat post.md | ssh cli.makenot.work blog ambient-textures --title "March Update" --publish ``` These are handled by checking the SSH "exec" request type. If the client sends a command instead of requesting a shell/pty, run it non-interactively and return output. ## Project Structure ``` mnw-cli/ ├── Cargo.toml ├── src/ │ ├── main.rs Entry point, russh server setup │ ├── config.rs CLI config (port, MNW API URL, staging dir) │ ├── ssh/ │ │ ├── mod.rs SSH server + handler │ │ ├── auth.rs Public key authentication │ │ ├── terminal.rs TerminalHandle (Write impl → SSH channel) │ │ └── commands.rs Non-interactive command dispatch │ ├── api/ │ │ ├── mod.rs MNW API client │ │ ├── projects.rs Project endpoints │ │ ├── items.rs Item CRUD + upload │ │ ├── analytics.rs Analytics endpoints │ │ ├── blog.rs Blog post endpoints │ │ ├── promo.rs Promo code endpoints │ │ ├── keys.rs License key endpoints │ │ └── account.rs User account endpoints │ ├── tui/ │ │ ├── mod.rs App state + event loop │ │ ├── home.rs Home screen │ │ ├── project.rs Project view │ │ ├── upload.rs Upload/staging screen │ │ ├── item.rs Item editor │ │ ├── analytics.rs Analytics dashboard │ │ ├── blog.rs Blog editor │ │ ├── promo.rs Promo code manager │ │ ├── keys.rs License key manager │ │ ├── settings.rs Settings screen │ │ └── widgets/ │ │ ├── mod.rs │ │ ├── table.rs Selectable table widget │ │ ├── input.rs Text input widget │ │ ├── editor.rs Multi-line markdown editor │ │ ├── progress.rs Upload progress bar │ │ └── chart.rs Revenue sparkline/bar chart │ └── staging/ │ ├── mod.rs Staging directory management │ └── detect.rs File type detection (same as web: audio extensions) └── tests/ ├── auth.rs Key lookup + rejection tests ├── commands.rs Non-interactive command tests └── upload.rs Staging + upload pipeline tests ``` ## MNW Changes Required ### New internal endpoint ```rust // routes/api/internal.rs (new file) /// Look up a user by SSH key fingerprint. /// Internal only — called by mnw-cli from localhost. GET /api/internal/ssh-key-lookup?fingerprint={sha256} → 200 { user_id, username, display_name, email, creator_tier, can_create_projects, suspended } → 404 { error: "key not found" } ``` Protected by either: - Localhost-only check (`request.remote_addr` is 127.0.0.1) - Shared secret header (`X-Internal-Token: {configured_secret}`) ### Service auth for API calls The CLI needs to call MNW API endpoints on behalf of authenticated users without a browser session. Options: **Option A: Internal service token + user impersonation** (recommended) - CLI sends `Authorization: Bearer {service_token}` + `X-On-Behalf-Of: {user_id}` - MNW validates the service token (shared secret), then treats the request as coming from that user - Simple, no per-user token management **Option B: Per-user API tokens** - Each SSH session generates a short-lived API token via the internal endpoint - Stored in memory for the session duration - More complex but more standard Recommend Option A for simplicity. ## Deployment ### DNS `cli.makenot.work` — A record → Hetzner public IP (proxy OFF in Cloudflare, same as `ssh.makenot.work`). SSH can't go through Cloudflare's HTTP proxy. ### Caddy Caddy doesn't handle SSH traffic. The mnw-cli binary listens directly on port 2222. Firewall opens 2222 to all. Or: use port 22 directly. But OpenSSH already occupies port 22 on the VPS for admin access. Solutions: - Move OpenSSH to a different port (e.g., 2200) and give mnw-cli port 22 - Keep OpenSSH on 22 for admin, mnw-cli on 2222, users connect with `ssh -p 2222 cli.makenot.work` - Use a TCP multiplexer (sslh) to route based on SSH protocol fingerprinting — fragile, not recommended **Recommendation**: mnw-cli on port 22, OpenSSH moved to 2200 (admin access via Tailscale anyway). Users get the clean `ssh cli.makenot.work` experience without specifying a port. Alternatively, since `ssh.makenot.work` already points to the same IP for git, and git SSH goes through OpenSSH — keep OpenSSH on 22, and use `ssh -p 2222 cli.makenot.work`. The port is slightly less clean but avoids conflicts. ### systemd ```ini [Unit] Description=MNW CLI SSH Server After=network.target makenotwork.service [Service] Type=simple ExecStart=/opt/mnw-cli/mnw-cli Environment=MNW_API_URL=http://localhost:3000 Environment=MNW_SERVICE_TOKEN= Environment=STAGING_DIR=/var/lib/mnw-cli/staging Environment=SSH_HOST_KEY=/etc/mnw-cli/host_ed25519 Restart=always User=mnw-cli [Install] WantedBy=multi-user.target ``` ### Host key Generate once, never change (TOFU model — users trust on first connect): ```bash ssh-keygen -t ed25519 -f /etc/mnw-cli/host_ed25519 -N "" ``` ### Staging directory Per-user staging dirs at `/var/lib/mnw-cli/staging/{user_id}/`. Cleaned up: - After successful publish - After 24 hours (cron or built-in cleanup task) - Max 1GB per user (reject SCP if exceeded) ## Implementation Phases ### Phase 1 — SSH skeleton + auth - [ ] Project setup (Cargo workspace member or standalone) - [ ] russh server with ed25519 host key - [ ] TerminalHandle (Write → SSH channel bridge) - [ ] ratatui rendering into SSH session - [ ] Public key auth via MNW internal endpoint - [ ] Rejection message for unregistered keys - [ ] MNW: internal SSH key lookup endpoint - [ ] MNW: service token auth middleware ### Phase 2 — Home + project views - [ ] Home screen (project list, quick stats) - [ ] Project view (item list with status) - [ ] Navigation (keyboard shortcuts, breadcrumbs) - [ ] Window resize handling - [ ] API client for projects + items ### Phase 3 — Upload pipeline - [ ] SCP/SFTP subsystem handler in russh - [ ] Per-user staging directory - [ ] Upload screen (list staged files, edit metadata) - [ ] Type auto-detection from file extension - [ ] Title derivation from filename - [ ] Presign → S3 upload → confirm pipeline - [ ] Progress tracking per file - [ ] Staging cleanup (after publish + timeout) ### Phase 4 — Item management - [ ] Item editor screen - [ ] Create item (type, title, description, price) - [ ] Edit item fields - [ ] Publish / unpublish - [ ] Bulk operations (select multiple, publish/unpublish/delete) - [ ] Version management - [ ] Tag management ### Phase 5 — Content + monetization - [ ] Blog post editor (markdown, title, scheduling) - [ ] Promo code management (create, list, delete) - [ ] Subscription tier management - [ ] License key generation + management - [ ] Collection management ### Phase 6 — Analytics + export - [ ] Analytics dashboard (revenue chart, top items, follower count) - [ ] Time range selection (week/month/year) - [ ] Export (CSV output to terminal or SCP download) - [ ] Transaction history ### Phase 7 — Command mode - [ ] SSH exec request handler (non-interactive commands) - [ ] `projects`, `analytics`, `publish`, `upload`, `promo`, `export`, `blog` - [ ] Pipe mode for uploads (`cat file | ssh cli.makenot.work upload ...`) - [ ] Machine-readable output (JSON flag) ### Phase 8 — Polish - [ ] Settings screen (profile, SSH keys, storage meter) - [ ] Broadcast to followers - [ ] Custom domain management - [ ] Error handling + reconnection hints - [ ] Rate limit awareness (back off + show message) - [ ] Graceful shutdown - [ ] Deploy script + systemd unit + monitoring (PoM health check) ## Key Paths - Design doc: `server/docs/cli.md` (this file) - Project code: `mnw-cli/` - MNW API routes: `MNW/server/src/routes/api/` - MNW SSH key storage: `MNW/server/src/db/` (user_ssh_keys queries) - MNW internal auth: `MNW/server/src/routes/api/internal.rs` (to be created) - Staging directory: `/var/lib/mnw-cli/staging/` (on server) - Host key: `/etc/mnw-cli/host_ed25519` (on server)