Skip to main content

max / makenotwork

33.8 KB · 618 lines History Blame Raw
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 | Component | Crate | Role |
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