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