| 1 |
# MNW CLI — SSH Terminal Interface |
| 2 |
|
| 3 |
## Overview |
| 4 |
|
| 5 |
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. |
| 6 |
|
| 7 |
Inspired by `ssh terminal.shop` (Charm/Go). Built in Rust with russh + ratatui. |
| 8 |
|
| 9 |
## Why |
| 10 |
|
| 11 |
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. |
| 12 |
|
| 13 |
SSH as transport means zero client installation — every machine already has an SSH client. |
| 14 |
|
| 15 |
## Architecture |
| 16 |
|
| 17 |
``` |
| 18 |
┌──────────────┐ SSH (port 2222) ┌──────────────────────┐ |
| 19 |
│ Creator's │ ◄─────────────────────► │ mnw-cli binary │ |
| 20 |
│ Terminal │ (russh server) │ (separate process) │ |
| 21 |
└──────────────┘ │ │ |
| 22 |
│ ┌────────────────┐ │ |
| 23 |
│ │ ratatui TUI │ │ |
| 24 |
│ │ (per-session) │ │ |
| 25 |
│ └────────┬───────┘ │ |
| 26 |
│ │ │ |
| 27 |
│ ┌────────▼───────┐ │ |
| 28 |
│ │ HTTP client │ │ |
| 29 |
│ │ → MNW API │ │ |
| 30 |
│ └────────┬───────┘ │ |
| 31 |
└───────────┼──────────┘ |
| 32 |
│ |
| 33 |
┌───────────▼──────────┐ |
| 34 |
│ MNW server │ |
| 35 |
│ (Axum, port 3000) │ |
| 36 |
│ PostgreSQL │ |
| 37 |
│ S3 │ |
| 38 |
└──────────────────────┘ |
| 39 |
``` |
| 40 |
|
| 41 |
### Separate binary, not embedded in MNW |
| 42 |
|
| 43 |
- `mnw-cli` is its own Rust binary with its own Cargo project |
| 44 |
- Talks to MNW via its existing JSON API (same endpoints the dashboard uses) |
| 45 |
- Runs on the same Hetzner VPS, connects to MNW at localhost:3000 |
| 46 |
- Caddy proxies SSH on port 22 or the binary listens on 2222 (Caddy can't proxy SSH — see Deployment) |
| 47 |
|
| 48 |
### Why API client, not direct DB access |
| 49 |
|
| 50 |
- MNW's API already handles auth, validation, storage tracking, Stripe, email notifications |
| 51 |
- No risk of bypassing business logic (tier limits, malware scanning, etc.) |
| 52 |
- CLI stays thin — a TUI skin over the API |
| 53 |
- Can run on a different machine if needed |
| 54 |
|
| 55 |
## Tech Stack |
| 56 |
|
| 57 |
|
| 58 |
|
| 59 |
| SSH server | `russh` (0.58+) | Accept connections, authenticate, manage sessions | |
| 60 |
| TUI framework | `ratatui` (0.30+) | Render UI into SSH channel | |
| 61 |
| Terminal backend | `crossterm` | Crossterm backend with custom Write impl | |
| 62 |
| HTTP client | `reqwest` | Call MNW API endpoints | |
| 63 |
| Async runtime | `tokio` | russh and reqwest both need it | |
| 64 |
| Key parsing | `ssh-key` (re-exported by russh) | Parse/fingerprint public keys | |
| 65 |
| S3 uploads | `reqwest` | PUT to presigned URLs | |
| 66 |
|
| 67 |
No new dependencies on MNW's side. The CLI is a pure API consumer. |
| 68 |
|
| 69 |
## Authentication |
| 70 |
|
| 71 |
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. |
| 72 |
|
| 73 |
### Flow |
| 74 |
|
| 75 |
1. Creator runs `ssh cli.makenot.work` |
| 76 |
2. russh server receives connection, calls `auth_publickey_offered` with the offered key |
| 77 |
3. Handler queries MNW API: `GET /api/internal/ssh-key-lookup?fingerprint={sha256}` (new internal endpoint) |
| 78 |
4. If key found → `Auth::Accept`, russh verifies signature, then `auth_publickey` fires |
| 79 |
5. Handler stores authenticated user info (user_id, username, display_name, creator_tier) |
| 80 |
6. TUI launches with full creator context |
| 81 |
7. All subsequent API calls use an internal service token + user_id header (no session cookie needed) |
| 82 |
|
| 83 |
### New MNW endpoint needed |
| 84 |
|
| 85 |
``` |
| 86 |
GET /api/internal/ssh-key-lookup?fingerprint={sha256_fingerprint} |
| 87 |
→ 200 { user_id, username, display_name, creator_tier, can_create_projects } |
| 88 |
→ 404 (key not found) |
| 89 |
``` |
| 90 |
|
| 91 |
Internal-only (localhost or shared secret). Not exposed publicly. |
| 92 |
|
| 93 |
### No key? Helpful rejection |
| 94 |
|
| 95 |
If the SSH key isn't registered, the CLI sends a message before disconnecting: |
| 96 |
|
| 97 |
``` |
| 98 |
Your SSH key is not linked to a Makenot.work account. |
| 99 |
|
| 100 |
To connect: |
| 101 |
1. Log in at makenot.work/dashboard |
| 102 |
2. Go to Settings → SSH Keys |
| 103 |
3. Add your public key (~/.ssh/id_ed25519.pub) |
| 104 |
|
| 105 |
Then try again: ssh cli.makenot.work |
| 106 |
``` |
| 107 |
|
| 108 |
## Screens |
| 109 |
|
| 110 |
### Home (after auth) |
| 111 |
|
| 112 |
``` |
| 113 |
┌─ Makenot.work ── max ── Small Files tier ── 142MB / 10GB ─────────┐ |
| 114 |
│ │ |
| 115 |
│ Projects │ |
| 116 |
│ ┌─────────────────────────────────────────────────────────────┐ │ |
| 117 |
│ │ ▸ ambient-textures 12 items $847 revenue 3 drafts │ │ |
| 118 |
│ │ sound-tools 4 items $203 revenue 0 drafts │ │ |
| 119 |
│ │ field-recordings 8 items $56 revenue 1 draft │ │ |
| 120 |
│ └─────────────────────────────────────────────────────────────┘ │ |
| 121 |
│ │ |
| 122 |
│ Quick Stats (last 30 days) │ |
| 123 |
│ Revenue: $312 Sales: 47 Followers: 89 Views: 1,204 │ |
| 124 |
│ │ |
| 125 |
│ [n] New project [u] Upload [b] Blog post [a] Analytics │ |
| 126 |
│ [p] Promo codes [e] Export [s] Settings [q] Quit │ |
| 127 |
└─────────────────────────────────────────────────────────────────────┘ |
| 128 |
``` |
| 129 |
|
| 130 |
### Project View |
| 131 |
|
| 132 |
``` |
| 133 |
┌─ ambient-textures ── Music ── 12 items ── $847 total ──────────────┐ |
| 134 |
│ │ |
| 135 |
│ Items Filter: [All ▾] │ |
| 136 |
│ ┌──────────────────────────────────────────────────────────────┐ │ |
| 137 |
│ │ ● dawn-chorus-vol-2 Audio $12.00 Published Mar 25 │ │ |
| 138 |
│ │ ● rain-on-glass Audio $8.00 Published Mar 20 │ │ |
| 139 |
│ │ ○ city-at-night Audio $0.00 Draft Mar 18 │ │ |
| 140 |
│ │ ● texture-pack-spring Bundle $24.00 Published Mar 15 │ │ |
| 141 |
│ │ ... │ │ |
| 142 |
│ └──────────────────────────────────────────────────────────────┘ │ |
| 143 |
│ │ |
| 144 |
│ [u] Upload new [n] New item [b] Blog [p] Promo [t] Tiers │ |
| 145 |
│ [Enter] Open item [d] Delete [Space] Select [P] Bulk publish │ |
| 146 |
│ [Esc] Back │ |
| 147 |
└─────────────────────────────────────────────────────────────────────┘ |
| 148 |
``` |
| 149 |
|
| 150 |
### Upload Flow (the killer feature) |
| 151 |
|
| 152 |
``` |
| 153 |
┌─ Upload to: ambient-textures ───────────────────────────────────────┐ |
| 154 |
│ │ |
| 155 |
│ Drop files or enter paths: │ |
| 156 |
│ > ~/Music/exports/dawn-chorus-v2.wav │ |
| 157 |
│ │ |
| 158 |
│ Queue: │ |
| 159 |
│ ┌──────────────────────────────────────────────────────────────┐ │ |
| 160 |
│ │ dawn-chorus-v2.wav Audio [dawn chorus v2 ] ██████ ✓ │ │ |
| 161 |
│ │ rain-textures.flac Audio [rain textures ] ████░░ 67%│ │ |
| 162 |
│ │ field-kit-march.zip Digital[field kit march ] ░░░░░░ Q │ │ |
| 163 |
│ └──────────────────────────────────────────────────────────────┘ │ |
| 164 |
│ │ |
| 165 |
│ Title auto-filled from filename. Tab to edit before upload. │ |
| 166 |
│ │ |
| 167 |
│ Price: [$8.00 ] PWYW: [off] Publish: [immediately] │ |
| 168 |
│ │ |
| 169 |
│ [Enter] Start upload [Tab] Edit field [Esc] Cancel │ |
| 170 |
└─────────────────────────────────────────────────────────────────────┘ |
| 171 |
``` |
| 172 |
|
| 173 |
Upload pipeline per file (same as web): |
| 174 |
1. Create item → `POST /api/projects/{pid}/items` (form: title, item_type, price_cents) |
| 175 |
2. For audio: `POST /api/upload/presign` → PUT to S3 → `POST /api/upload/confirm` |
| 176 |
3. For other: `POST /api/items/{id}/versions` → `POST /api/versions/{vid}/upload/presign` → PUT to S3 → confirm |
| 177 |
4. Publish if requested |
| 178 |
|
| 179 |
**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. |
| 180 |
|
| 181 |
Wait — that's wrong. The SSH session runs on the server. The creator's local files aren't accessible. |
| 182 |
|
| 183 |
### File Upload: The SSH Problem |
| 184 |
|
| 185 |
SSH TUI apps run server-side. The creator's local files aren't accessible from the server process. Solutions: |
| 186 |
|
| 187 |
**Option A: SCP/SFTP sidecar** (recommended) |
| 188 |
- The SSH server also handles SCP/SFTP subsystem requests |
| 189 |
- Creator uploads files first: `scp track.wav cli.makenot.work:upload/` |
| 190 |
- Files land in a per-user staging directory on the server |
| 191 |
- TUI shows staged files, creator assigns titles/types/prices, then publishes |
| 192 |
- Staging dir cleaned after publish or on timeout |
| 193 |
|
| 194 |
```bash |
| 195 |
# Upload files |
| 196 |
scp *.wav cli.makenot.work:upload/ |
| 197 |
|
| 198 |
# Then connect to manage them |
| 199 |
ssh cli.makenot.work |
| 200 |
``` |
| 201 |
|
| 202 |
Or in one flow with SSH command mode: |
| 203 |
```bash |
| 204 |
# Upload + auto-publish |
| 205 |
scp track.wav cli.makenot.work:upload/ambient-textures/ |
| 206 |
ssh cli.makenot.work publish ambient-textures # publishes all staged files |
| 207 |
``` |
| 208 |
|
| 209 |
**Option B: Pipe mode** |
| 210 |
```bash |
| 211 |
# Pipe a file directly |
| 212 |
cat track.wav | ssh cli.makenot.work upload --project ambient-textures --title "Dawn Chorus" --type audio |
| 213 |
``` |
| 214 |
|
| 215 |
**Option C: Local CLI client** (complementary, not primary) |
| 216 |
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. |
| 217 |
|
| 218 |
**Recommendation**: Option A (SCP staging) as primary. Option B (pipe) as shortcut for single files. |
| 219 |
|
| 220 |
### Upload Screen (with staging) |
| 221 |
|
| 222 |
``` |
| 223 |
┌─ Upload to: ambient-textures ───────────────────────────────────────┐ |
| 224 |
│ │ |
| 225 |
│ Staged files (scp'd to cli.makenot.work:upload/) │ |
| 226 |
│ ┌──────────────────────────────────────────────────────────────┐ │ |
| 227 |
│ │ ✓ dawn-chorus-v2.wav 43MB Audio [dawn chorus v2 ] │ │ |
| 228 |
│ │ ✓ rain-textures.flac 67MB Audio [rain textures ] │ │ |
| 229 |
│ │ ✓ field-kit-march.zip 12MB Digital[field kit march ] │ │ |
| 230 |
│ └──────────────────────────────────────────────────────────────┘ │ |
| 231 |
│ │ |
| 232 |
│ Defaults for all: │ |
| 233 |
│ Price: [$8.00 ] PWYW: [off] Publish: [immediately] │ |
| 234 |
│ │ |
| 235 |
│ [Enter] Upload + publish all [Tab] Edit per-file [Esc] Cancel │ |
| 236 |
└─────────────────────────────────────────────────────────────────────┘ |
| 237 |
``` |
| 238 |
|
| 239 |
### Item Editor |
| 240 |
|
| 241 |
``` |
| 242 |
┌─ dawn-chorus-vol-2 ── Audio ── $12.00 ── Published ────────────────┐ |
| 243 |
│ │ |
| 244 |
│ Title: [dawn chorus vol 2 ] │ |
| 245 |
│ Description: [Layered field recordings from sunrise sessions ] │ |
| 246 |
│ [in Olympic National Park. 24-bit WAV. ] │ |
| 247 |
│ Price: [$12.00 ] PWYW: [on ] Min: [$5.00] │ |
| 248 |
│ Tags: ambient, field-recording, nature │ |
| 249 |
│ Status: Published (Mar 25, 2026) │ |
| 250 |
│ File: dawn-chorus-v2.wav (43MB, v1.0) │ |
| 251 |
│ Downloads: 127 │ |
| 252 |
│ │ |
| 253 |
│ Versions: │ |
| 254 |
│ ┌──────────────────────────────────────────────────────────┐ │ |
| 255 |
│ │ v1.0 dawn-chorus-v2.wav 43MB Mar 25 │ │ |
| 256 |
│ │ v0.9 dawn-chorus-v2-beta.wav 41MB Mar 20 │ │ |
| 257 |
│ └──────────────────────────────────────────────────────────┘ │ |
| 258 |
│ │ |
| 259 |
│ [e] Edit fields [v] New version [u] Unpublish [d] Delete │ |
| 260 |
│ [c] Chapters [t] Tags [Esc] Back │ |
| 261 |
└─────────────────────────────────────────────────────────────────────┘ |
| 262 |
``` |
| 263 |
|
| 264 |
### Analytics |
| 265 |
|
| 266 |
``` |
| 267 |
┌─ Analytics ── Last 30 days ──────────────────────────────────────────┐ |
| 268 |
│ │ |
| 269 |
│ Revenue Sales Followers Views │ |
| 270 |
│ $312.00 47 89 (+12) 1,204 │ |
| 271 |
│ │ |
| 272 |
│ Revenue by day: │ |
| 273 |
│ $40 │ ╷ │ |
| 274 |
│ $30 │ ╷ │ ╷ │ |
| 275 |
│ $20 │ ╷ │ ╷ │ │ ╷ │ |
| 276 |
│ $10 │ │ │ │ │╷ │ │ ╷ ╷ ╷ │ |
| 277 |
│ $0 └─┴─┴─┴─┴┴─┴─┴─┴──┴─────┴──────────────────── │ |
| 278 |
│ 1 3 5 7 9 11 13 15 17 19 21 23 25 27 │ |
| 279 |
│ │ |
| 280 |
│ Top items: │ |
| 281 |
│ 1. dawn-chorus-vol-2 $144 (12 sales) │ |
| 282 |
│ 2. texture-pack-spring $96 (4 sales) │ |
| 283 |
│ 3. rain-on-glass $72 (9 sales) │ |
| 284 |
│ │ |
| 285 |
│ [w] Week [m] Month [y] Year [e] Export CSV [Esc] Back │ |
| 286 |
└──────────────────────────────────────────────────────────────────────┘ |
| 287 |
``` |
| 288 |
|
| 289 |
### Blog Post Editor |
| 290 |
|
| 291 |
``` |
| 292 |
┌─ New Blog Post ── ambient-textures ──────────────────────────────────┐ |
| 293 |
│ │ |
| 294 |
│ Title: [March field recording roundup ] │ |
| 295 |
│ Slug: [march-field-recording-roundup ] │ |
| 296 |
│ │ |
| 297 |
│ ┌── Markdown ──────────────────────────────────────────────────┐ │ |
| 298 |
│ │ ## What I recorded this month │ │ |
| 299 |
│ │ │ │ |
| 300 |
│ │ Three new locations in the Pacific Northwest: │ │ |
| 301 |
│ │ │ │ |
| 302 |
│ │ 1. **Hoh Rainforest** — moss-dampened bird calls at dawn │ │ |
| 303 |
│ │ 2. **Ruby Beach** — wave textures on cobblestone │ │ |
| 304 |
│ │ 3. **Hurricane Ridge** — wind through subalpine fir │ │ |
| 305 |
│ │ │ │ |
| 306 |
│ │ Each recording is 20+ minutes of unprocessed audio. │ │ |
| 307 |
│ │ ~ │ │ |
| 308 |
│ └──────────────────────────────────────────────────────────────┘ │ |
| 309 |
│ │ |
| 310 |
│ Publish: [now ▾] Email followers: [yes] Web only: [no] │ |
| 311 |
│ │ |
| 312 |
│ [Ctrl+S] Save draft [Ctrl+P] Publish [Esc] Cancel │ |
| 313 |
└──────────────────────────────────────────────────────────────────────┘ |
| 314 |
``` |
| 315 |
|
| 316 |
### Promo Codes |
| 317 |
|
| 318 |
``` |
| 319 |
┌─ Promo Codes ── ambient-textures ────────────────────────────────────┐ |
| 320 |
│ │ |
| 321 |
│ Active codes: │ |
| 322 |
│ ┌──────────────────────────────────────────────────────────────┐ │ |
| 323 |
│ │ SPRING25 25% off All items 47 uses No limit │ │ |
| 324 |
│ │ FREESAMPLE Free rain-on-glass 12 uses 50 max │ │ |
| 325 |
│ │ BETATESTERS $5 off All items 3 uses 10 max │ │ |
| 326 |
│ └──────────────────────────────────────────────────────────────┘ │ |
| 327 |
│ │ |
| 328 |
│ [n] New code [d] Delete [Esc] Back │ |
| 329 |
└──────────────────────────────────────────────────────────────────────┘ |
| 330 |
``` |
| 331 |
|
| 332 |
### License Keys (software creators) |
| 333 |
|
| 334 |
``` |
| 335 |
┌─ License Keys ── sound-tools ── audio-splitter-pro ──────────────────┐ |
| 336 |
│ │ |
| 337 |
│ Settings: Max activations per key: 3 │ |
| 338 |
│ │ |
| 339 |
│ Keys: │ |
| 340 |
│ ┌──────────────────────────────────────────────────────────────┐ │ |
| 341 |
│ │ ABCD-1234-EFGH Active 2/3 activations Mar 25 │ │ |
| 342 |
│ │ IJKL-5678-MNOP Active 1/3 activations Mar 20 │ │ |
| 343 |
│ │ QRST-9012-UVWX Revoked 0/3 activations Mar 15 │ │ |
| 344 |
│ └──────────────────────────────────────────────────────────────┘ │ |
| 345 |
│ │ |
| 346 |
│ [g] Generate keys [r] Revoke [Esc] Back │ |
| 347 |
└──────────────────────────────────────────────────────────────────────┘ |
| 348 |
``` |
| 349 |
|
| 350 |
### Settings |
| 351 |
|
| 352 |
``` |
| 353 |
┌─ Settings ───────────────────────────────────────────────────────────┐ |
| 354 |
│ │ |
| 355 |
│ Account │ |
| 356 |
│ Username: max │ |
| 357 |
│ Display name: [Max J ] │ |
| 358 |
│ Email: max@example.com (verified) │ |
| 359 |
│ Tier: Small Files ($24/mo) │ |
| 360 |
│ Storage: 142MB / 10GB ██░░░░░░░░ 1.4% │ |
| 361 |
│ │ |
| 362 |
│ Security │ |
| 363 |
│ 2FA: TOTP enabled │ |
| 364 |
│ SSH Keys: 2 registered │ |
| 365 |
│ Sessions: 3 active │ |
| 366 |
│ │ |
| 367 |
│ SSH Keys: │ |
| 368 |
│ ┌──────────────────────────────────────────────────────────────┐ │ |
| 369 |
│ │ laptop ssh-ed25519 SHA256:abc... Added Mar 10 │ │ |
| 370 |
│ │ desktop ssh-ed25519 SHA256:def... Added Feb 28 │ │ |
| 371 |
│ └──────────────────────────────────────────────────────────────┘ │ |
| 372 |
│ │ |
| 373 |
│ [e] Edit profile [k] Manage keys [x] Export data [Esc] Back │ |
| 374 |
└──────────────────────────────────────────────────────────────────────┘ |
| 375 |
``` |
| 376 |
|
| 377 |
## SSH Command Mode |
| 378 |
|
| 379 |
Beyond the interactive TUI, support direct commands via SSH: |
| 380 |
|
| 381 |
```bash |
| 382 |
# Quick publish staged files |
| 383 |
ssh cli.makenot.work publish ambient-textures |
| 384 |
|
| 385 |
# List projects |
| 386 |
ssh cli.makenot.work projects |
| 387 |
|
| 388 |
# View analytics |
| 389 |
ssh cli.makenot.work analytics --last 30d |
| 390 |
|
| 391 |
# Generate promo code |
| 392 |
ssh cli.makenot.work promo ambient-textures --code SPRING25 --discount 25% |
| 393 |
|
| 394 |
# Upload and publish in one shot (pipe mode) |
| 395 |
cat track.wav | ssh cli.makenot.work upload ambient-textures --title "New Track" --type audio --price 800 |
| 396 |
|
| 397 |
# Export sales data |
| 398 |
ssh cli.makenot.work export sales --format csv > sales.csv |
| 399 |
|
| 400 |
# Blog post from file |
| 401 |
cat post.md | ssh cli.makenot.work blog ambient-textures --title "March Update" --publish |
| 402 |
``` |
| 403 |
|
| 404 |
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. |
| 405 |
|
| 406 |
## Project Structure |
| 407 |
|
| 408 |
``` |
| 409 |
mnw-cli/ |
| 410 |
├── Cargo.toml |
| 411 |
├── src/ |
| 412 |
│ ├── main.rs Entry point, russh server setup |
| 413 |
│ ├── config.rs CLI config (port, MNW API URL, staging dir) |
| 414 |
│ ├── ssh/ |
| 415 |
│ │ ├── mod.rs SSH server + handler |
| 416 |
│ │ ├── auth.rs Public key authentication |
| 417 |
│ │ ├── terminal.rs TerminalHandle (Write impl → SSH channel) |
| 418 |
│ │ └── commands.rs Non-interactive command dispatch |
| 419 |
│ ├── api/ |
| 420 |
│ │ ├── mod.rs MNW API client |
| 421 |
│ │ ├── projects.rs Project endpoints |
| 422 |
│ │ ├── items.rs Item CRUD + upload |
| 423 |
│ │ ├── analytics.rs Analytics endpoints |
| 424 |
│ │ ├── blog.rs Blog post endpoints |
| 425 |
│ │ ├── promo.rs Promo code endpoints |
| 426 |
│ │ ├── keys.rs License key endpoints |
| 427 |
│ │ └── account.rs User account endpoints |
| 428 |
│ ├── tui/ |
| 429 |
│ │ ├── mod.rs App state + event loop |
| 430 |
│ │ ├── home.rs Home screen |
| 431 |
│ │ ├── project.rs Project view |
| 432 |
│ │ ├── upload.rs Upload/staging screen |
| 433 |
│ │ ├── item.rs Item editor |
| 434 |
│ │ ├── analytics.rs Analytics dashboard |
| 435 |
│ │ ├── blog.rs Blog editor |
| 436 |
│ │ ├── promo.rs Promo code manager |
| 437 |
│ │ ├── keys.rs License key manager |
| 438 |
│ │ ├── settings.rs Settings screen |
| 439 |
│ │ └── widgets/ |
| 440 |
│ │ ├── mod.rs |
| 441 |
│ │ ├── table.rs Selectable table widget |
| 442 |
│ │ ├── input.rs Text input widget |
| 443 |
│ │ ├── editor.rs Multi-line markdown editor |
| 444 |
│ │ ├── progress.rs Upload progress bar |
| 445 |
│ │ └── chart.rs Revenue sparkline/bar chart |
| 446 |
│ └── staging/ |
| 447 |
│ ├── mod.rs Staging directory management |
| 448 |
│ └── detect.rs File type detection (same as web: audio extensions) |
| 449 |
└── tests/ |
| 450 |
├── auth.rs Key lookup + rejection tests |
| 451 |
├── commands.rs Non-interactive command tests |
| 452 |
└── upload.rs Staging + upload pipeline tests |
| 453 |
``` |
| 454 |
|
| 455 |
## MNW Changes Required |
| 456 |
|
| 457 |
### New internal endpoint |
| 458 |
|
| 459 |
```rust |
| 460 |
// routes/api/internal.rs (new file) |
| 461 |
|
| 462 |
/// Look up a user by SSH key fingerprint. |
| 463 |
/// Internal only — called by mnw-cli from localhost. |
| 464 |
GET /api/internal/ssh-key-lookup?fingerprint={sha256} |
| 465 |
→ 200 { user_id, username, display_name, email, creator_tier, can_create_projects, suspended } |
| 466 |
→ 404 { error: "key not found" } |
| 467 |
``` |
| 468 |
|
| 469 |
Protected by either: |
| 470 |
- Localhost-only check (`request.remote_addr` is 127.0.0.1) |
| 471 |
- Shared secret header (`X-Internal-Token: {configured_secret}`) |
| 472 |
|
| 473 |
### Service auth for API calls |
| 474 |
|
| 475 |
The CLI needs to call MNW API endpoints on behalf of authenticated users without a browser session. Options: |
| 476 |
|
| 477 |
**Option A: Internal service token + user impersonation** (recommended) |
| 478 |
- CLI sends `Authorization: Bearer {service_token}` + `X-On-Behalf-Of: {user_id}` |
| 479 |
- MNW validates the service token (shared secret), then treats the request as coming from that user |
| 480 |
- Simple, no per-user token management |
| 481 |
|
| 482 |
**Option B: Per-user API tokens** |
| 483 |
- Each SSH session generates a short-lived API token via the internal endpoint |
| 484 |
- Stored in memory for the session duration |
| 485 |
- More complex but more standard |
| 486 |
|
| 487 |
Recommend Option A for simplicity. |
| 488 |
|
| 489 |
## Deployment |
| 490 |
|
| 491 |
### DNS |
| 492 |
|
| 493 |
`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. |
| 494 |
|
| 495 |
### Caddy |
| 496 |
|
| 497 |
Caddy doesn't handle SSH traffic. The mnw-cli binary listens directly on port 2222. Firewall opens 2222 to all. |
| 498 |
|
| 499 |
Or: use port 22 directly. But OpenSSH already occupies port 22 on the VPS for admin access. Solutions: |
| 500 |
- Move OpenSSH to a different port (e.g., 2200) and give mnw-cli port 22 |
| 501 |
- Keep OpenSSH on 22 for admin, mnw-cli on 2222, users connect with `ssh -p 2222 cli.makenot.work` |
| 502 |
- Use a TCP multiplexer (sslh) to route based on SSH protocol fingerprinting — fragile, not recommended |
| 503 |
|
| 504 |
**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. |
| 505 |
|
| 506 |
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. |
| 507 |
|
| 508 |
### systemd |
| 509 |
|
| 510 |
```ini |
| 511 |
[Unit] |
| 512 |
Description=MNW CLI SSH Server |
| 513 |
After=network.target makenotwork.service |
| 514 |
|
| 515 |
[Service] |
| 516 |
Type=simple |
| 517 |
ExecStart=/opt/mnw-cli/mnw-cli |
| 518 |
Environment=MNW_API_URL=http://localhost:3000 |
| 519 |
Environment=MNW_SERVICE_TOKEN=<secret> |
| 520 |
Environment=STAGING_DIR=/var/lib/mnw-cli/staging |
| 521 |
Environment=SSH_HOST_KEY=/etc/mnw-cli/host_ed25519 |
| 522 |
Restart=always |
| 523 |
User=mnw-cli |
| 524 |
|
| 525 |
[Install] |
| 526 |
WantedBy=multi-user.target |
| 527 |
``` |
| 528 |
|
| 529 |
### Host key |
| 530 |
|
| 531 |
Generate once, never change (TOFU model — users trust on first connect): |
| 532 |
```bash |
| 533 |
ssh-keygen -t ed25519 -f /etc/mnw-cli/host_ed25519 -N "" |
| 534 |
``` |
| 535 |
|
| 536 |
### Staging directory |
| 537 |
|
| 538 |
Per-user staging dirs at `/var/lib/mnw-cli/staging/{user_id}/`. Cleaned up: |
| 539 |
- After successful publish |
| 540 |
- After 24 hours (cron or built-in cleanup task) |
| 541 |
- Max 1GB per user (reject SCP if exceeded) |
| 542 |
|
| 543 |
## Implementation Phases |
| 544 |
|
| 545 |
### Phase 1 — SSH skeleton + auth |
| 546 |
- [ ] Project setup (Cargo workspace member or standalone) |
| 547 |
- [ ] russh server with ed25519 host key |
| 548 |
- [ ] TerminalHandle (Write → SSH channel bridge) |
| 549 |
- [ ] ratatui rendering into SSH session |
| 550 |
- [ ] Public key auth via MNW internal endpoint |
| 551 |
- [ ] Rejection message for unregistered keys |
| 552 |
- [ ] MNW: internal SSH key lookup endpoint |
| 553 |
- [ ] MNW: service token auth middleware |
| 554 |
|
| 555 |
### Phase 2 — Home + project views |
| 556 |
- [ ] Home screen (project list, quick stats) |
| 557 |
- [ ] Project view (item list with status) |
| 558 |
- [ ] Navigation (keyboard shortcuts, breadcrumbs) |
| 559 |
- [ ] Window resize handling |
| 560 |
- [ ] API client for projects + items |
| 561 |
|
| 562 |
### Phase 3 — Upload pipeline |
| 563 |
- [ ] SCP/SFTP subsystem handler in russh |
| 564 |
- [ ] Per-user staging directory |
| 565 |
- [ ] Upload screen (list staged files, edit metadata) |
| 566 |
- [ ] Type auto-detection from file extension |
| 567 |
- [ ] Title derivation from filename |
| 568 |
- [ ] Presign → S3 upload → confirm pipeline |
| 569 |
- [ ] Progress tracking per file |
| 570 |
- [ ] Staging cleanup (after publish + timeout) |
| 571 |
|
| 572 |
### Phase 4 — Item management |
| 573 |
- [ ] Item editor screen |
| 574 |
- [ ] Create item (type, title, description, price) |
| 575 |
- [ ] Edit item fields |
| 576 |
- [ ] Publish / unpublish |
| 577 |
- [ ] Bulk operations (select multiple, publish/unpublish/delete) |
| 578 |
- [ ] Version management |
| 579 |
- [ ] Tag management |
| 580 |
|
| 581 |
### Phase 5 — Content + monetization |
| 582 |
- [ ] Blog post editor (markdown, title, scheduling) |
| 583 |
- [ ] Promo code management (create, list, delete) |
| 584 |
- [ ] Subscription tier management |
| 585 |
- [ ] License key generation + management |
| 586 |
- [ ] Collection management |
| 587 |
|
| 588 |
### Phase 6 — Analytics + export |
| 589 |
- [ ] Analytics dashboard (revenue chart, top items, follower count) |
| 590 |
- [ ] Time range selection (week/month/year) |
| 591 |
- [ ] Export (CSV output to terminal or SCP download) |
| 592 |
- [ ] Transaction history |
| 593 |
|
| 594 |
### Phase 7 — Command mode |
| 595 |
- [ ] SSH exec request handler (non-interactive commands) |
| 596 |
- [ ] `projects`, `analytics`, `publish`, `upload`, `promo`, `export`, `blog` |
| 597 |
- [ ] Pipe mode for uploads (`cat file | ssh cli.makenot.work upload ...`) |
| 598 |
- [ ] Machine-readable output (JSON flag) |
| 599 |
|
| 600 |
### Phase 8 — Polish |
| 601 |
- [ ] Settings screen (profile, SSH keys, storage meter) |
| 602 |
- [ ] Broadcast to followers |
| 603 |
- [ ] Custom domain management |
| 604 |
- [ ] Error handling + reconnection hints |
| 605 |
- [ ] Rate limit awareness (back off + show message) |
| 606 |
- [ ] Graceful shutdown |
| 607 |
- [ ] Deploy script + systemd unit + monitoring (PoM health check) |
| 608 |
|
| 609 |
## Key Paths |
| 610 |
|
| 611 |
- Design doc: `server/docs/cli.md` (this file) |
| 612 |
- Project code: `mnw-cli/` |
| 613 |
- MNW API routes: `MNW/server/src/routes/api/` |
| 614 |
- MNW SSH key storage: `MNW/server/src/db/` (user_ssh_keys queries) |
| 615 |
- MNW internal auth: `MNW/server/src/routes/api/internal.rs` (to be created) |
| 616 |
- Staging directory: `/var/lib/mnw-cli/staging/` (on server) |
| 617 |
- Host key: `/etc/mnw-cli/host_ed25519` (on server) |
| 618 |
|