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