Skip to main content

max / mnw-cli

Initial commit: MNW CLI SSH server (Phases 1-8) SSH-based TUI for the Makenot.work creator platform. Authenticates creators by registered SSH public keys, presents a ratatui terminal UI for managing projects, items, uploads, analytics, and settings. Phases: SSH auth, home/project views, SFTP upload pipeline, item detail/mutations, blog/promo/license-key screens, analytics/export, non-interactive command mode, settings/graceful-shutdown/deploy. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-03-29 20:46 UTC
Commit: c5e6608a61055947ad4524162a4b47ab783b533b
24 files changed, +5276 insertions, -0 deletions
A .gitignore +1
@@ -0,0 +1 @@
1 + /target
A Cargo.lock +500
@@ -0,0 +1,4432 @@
1 + # This file is automatically @generated by Cargo.
2 + # It is not intended for manual editing.
3 + version = 4
4 +
5 + [[package]]
6 + name = "adler2"
7 + version = "2.0.1"
8 + source = "registry+https://github.com/rust-lang/crates.io-index"
9 + checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
10 +
11 + [[package]]
12 + name = "aead"
13 + version = "0.5.2"
14 + source = "registry+https://github.com/rust-lang/crates.io-index"
15 + checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
16 + dependencies = [
17 + "crypto-common 0.1.7",
18 + "generic-array 0.14.7",
19 + ]
20 +
21 + [[package]]
22 + name = "aes"
23 + version = "0.8.4"
24 + source = "registry+https://github.com/rust-lang/crates.io-index"
25 + checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
26 + dependencies = [
27 + "cfg-if",
28 + "cipher",
29 + "cpufeatures 0.2.17",
30 + ]
31 +
32 + [[package]]
33 + name = "aes-gcm"
34 + version = "0.10.3"
35 + source = "registry+https://github.com/rust-lang/crates.io-index"
36 + checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1"
37 + dependencies = [
38 + "aead",
39 + "aes",
40 + "cipher",
41 + "ctr",
42 + "ghash",
43 + "subtle",
44 + ]
45 +
46 + [[package]]
47 + name = "ahash"
48 + version = "0.8.12"
49 + source = "registry+https://github.com/rust-lang/crates.io-index"
50 + checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
51 + dependencies = [
52 + "cfg-if",
53 + "const-random",
54 + "once_cell",
55 + "version_check",
56 + "zerocopy",
57 + ]
58 +
59 + [[package]]
60 + name = "aho-corasick"
61 + version = "1.1.4"
62 + source = "registry+https://github.com/rust-lang/crates.io-index"
63 + checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
64 + dependencies = [
65 + "memchr",
66 + ]
67 +
68 + [[package]]
69 + name = "allocator-api2"
70 + version = "0.2.21"
71 + source = "registry+https://github.com/rust-lang/crates.io-index"
72 + checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
73 +
74 + [[package]]
75 + name = "android_system_properties"
76 + version = "0.1.5"
77 + source = "registry+https://github.com/rust-lang/crates.io-index"
78 + checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
79 + dependencies = [
80 + "libc",
81 + ]
82 +
83 + [[package]]
84 + name = "anyhow"
85 + version = "1.0.102"
86 + source = "registry+https://github.com/rust-lang/crates.io-index"
87 + checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
88 +
89 + [[package]]
90 + name = "argon2"
91 + version = "0.5.3"
92 + source = "registry+https://github.com/rust-lang/crates.io-index"
93 + checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
94 + dependencies = [
95 + "base64ct",
96 + "blake2",
97 + "cpufeatures 0.2.17",
98 + "password-hash",
99 + ]
100 +
101 + [[package]]
102 + name = "atomic"
103 + version = "0.6.1"
104 + source = "registry+https://github.com/rust-lang/crates.io-index"
105 + checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340"
106 + dependencies = [
107 + "bytemuck",
108 + ]
109 +
110 + [[package]]
111 + name = "atomic-waker"
112 + version = "1.1.2"
113 + source = "registry+https://github.com/rust-lang/crates.io-index"
114 + checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
115 +
116 + [[package]]
117 + name = "autocfg"
118 + version = "1.5.0"
119 + source = "registry+https://github.com/rust-lang/crates.io-index"
120 + checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
121 +
122 + [[package]]
123 + name = "aws-lc-rs"
124 + version = "1.16.2"
125 + source = "registry+https://github.com/rust-lang/crates.io-index"
126 + checksum = "a054912289d18629dc78375ba2c3726a3afe3ff71b4edba9dedfca0e3446d1fc"
127 + dependencies = [
128 + "aws-lc-sys",
129 + "untrusted 0.7.1",
130 + "zeroize",
131 + ]
132 +
133 + [[package]]
134 + name = "aws-lc-sys"
135 + version = "0.39.1"
136 + source = "registry+https://github.com/rust-lang/crates.io-index"
137 + checksum = "83a25cf98105baa966497416dbd42565ce3a8cf8dbfd59803ec9ad46f3126399"
138 + dependencies = [
139 + "cc",
140 + "cmake",
141 + "dunce",
142 + "fs_extra",
143 + ]
144 +
145 + [[package]]
146 + name = "base16ct"
147 + version = "0.2.0"
148 + source = "registry+https://github.com/rust-lang/crates.io-index"
149 + checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf"
150 +
151 + [[package]]
152 + name = "base16ct"
153 + version = "1.0.0"
154 + source = "registry+https://github.com/rust-lang/crates.io-index"
155 + checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6"
156 +
157 + [[package]]
158 + name = "base64"
159 + version = "0.22.1"
160 + source = "registry+https://github.com/rust-lang/crates.io-index"
161 + checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
162 +
163 + [[package]]
164 + name = "base64ct"
165 + version = "1.8.3"
166 + source = "registry+https://github.com/rust-lang/crates.io-index"
167 + checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
168 +
169 + [[package]]
170 + name = "bcrypt-pbkdf"
171 + version = "0.10.0"
172 + source = "registry+https://github.com/rust-lang/crates.io-index"
173 + checksum = "6aeac2e1fe888769f34f05ac343bbef98b14d1ffb292ab69d4608b3abc86f2a2"
174 + dependencies = [
175 + "blowfish",
176 + "pbkdf2",
177 + "sha2 0.10.9",
178 + ]
179 +
180 + [[package]]
181 + name = "bit-set"
182 + version = "0.5.3"
183 + source = "registry+https://github.com/rust-lang/crates.io-index"
184 + checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1"
185 + dependencies = [
186 + "bit-vec",
187 + ]
188 +
189 + [[package]]
190 + name = "bit-vec"
191 + version = "0.6.3"
192 + source = "registry+https://github.com/rust-lang/crates.io-index"
193 + checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb"
194 +
195 + [[package]]
196 + name = "bitflags"
197 + version = "1.3.2"
198 + source = "registry+https://github.com/rust-lang/crates.io-index"
199 + checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
200 +
201 + [[package]]
202 + name = "bitflags"
203 + version = "2.11.0"
204 + source = "registry+https://github.com/rust-lang/crates.io-index"
205 + checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
206 + dependencies = [
207 + "serde_core",
208 + ]
209 +
210 + [[package]]
211 + name = "blake2"
212 + version = "0.10.6"
213 + source = "registry+https://github.com/rust-lang/crates.io-index"
214 + checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
215 + dependencies = [
216 + "digest 0.10.7",
217 + ]
218 +
219 + [[package]]
220 + name = "block-buffer"
221 + version = "0.10.4"
222 + source = "registry+https://github.com/rust-lang/crates.io-index"
223 + checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
224 + dependencies = [
225 + "generic-array 0.14.7",
226 + ]
227 +
228 + [[package]]
229 + name = "block-buffer"
230 + version = "0.12.0"
231 + source = "registry+https://github.com/rust-lang/crates.io-index"
232 + checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be"
233 + dependencies = [
234 + "hybrid-array 0.4.8",
235 + ]
236 +
237 + [[package]]
238 + name = "block-padding"
239 + version = "0.3.3"
240 + source = "registry+https://github.com/rust-lang/crates.io-index"
241 + checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93"
242 + dependencies = [
243 + "generic-array 0.14.7",
244 + ]
245 +
246 + [[package]]
247 + name = "blowfish"
248 + version = "0.9.1"
249 + source = "registry+https://github.com/rust-lang/crates.io-index"
250 + checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7"
251 + dependencies = [
252 + "byteorder",
253 + "cipher",
254 + ]
255 +
256 + [[package]]
257 + name = "bumpalo"
258 + version = "3.20.2"
259 + source = "registry+https://github.com/rust-lang/crates.io-index"
260 + checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
261 +
262 + [[package]]
263 + name = "bytemuck"
264 + version = "1.25.0"
265 + source = "registry+https://github.com/rust-lang/crates.io-index"
266 + checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec"
267 +
268 + [[package]]
269 + name = "byteorder"
270 + version = "1.5.0"
271 + source = "registry+https://github.com/rust-lang/crates.io-index"
272 + checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
273 +
274 + [[package]]
275 + name = "bytes"
276 + version = "1.11.1"
277 + source = "registry+https://github.com/rust-lang/crates.io-index"
278 + checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
279 +
280 + [[package]]
281 + name = "castaway"
282 + version = "0.2.4"
283 + source = "registry+https://github.com/rust-lang/crates.io-index"
284 + checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a"
285 + dependencies = [
286 + "rustversion",
287 + ]
288 +
289 + [[package]]
290 + name = "cbc"
291 + version = "0.1.2"
292 + source = "registry+https://github.com/rust-lang/crates.io-index"
293 + checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6"
294 + dependencies = [
295 + "cipher",
296 + ]
297 +
298 + [[package]]
299 + name = "cc"
300 + version = "1.2.58"
301 + source = "registry+https://github.com/rust-lang/crates.io-index"
302 + checksum = "e1e928d4b69e3077709075a938a05ffbedfa53a84c8f766efbf8220bb1ff60e1"
303 + dependencies = [
304 + "find-msvc-tools",
305 + "jobserver",
306 + "libc",
307 + "shlex",
308 + ]
309 +
310 + [[package]]
311 + name = "cfg-if"
312 + version = "1.0.4"
313 + source = "registry+https://github.com/rust-lang/crates.io-index"
314 + checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
315 +
316 + [[package]]
317 + name = "cfg_aliases"
318 + version = "0.2.1"
319 + source = "registry+https://github.com/rust-lang/crates.io-index"
320 + checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
321 +
322 + [[package]]
323 + name = "chacha20"
324 + version = "0.9.1"
325 + source = "registry+https://github.com/rust-lang/crates.io-index"
326 + checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818"
327 + dependencies = [
328 + "cfg-if",
329 + "cipher",
330 + "cpufeatures 0.2.17",
331 + ]
332 +
333 + [[package]]
334 + name = "chrono"
335 + version = "0.4.44"
336 + source = "registry+https://github.com/rust-lang/crates.io-index"
337 + checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0"
338 + dependencies = [
339 + "iana-time-zone",
340 + "js-sys",
341 + "num-traits",
342 + "wasm-bindgen",
343 + "windows-link",
344 + ]
345 +
346 + [[package]]
347 + name = "cipher"
348 + version = "0.4.4"
349 + source = "registry+https://github.com/rust-lang/crates.io-index"
350 + checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
351 + dependencies = [
352 + "crypto-common 0.1.7",
353 + "inout",
354 + ]
355 +
356 + [[package]]
357 + name = "cmake"
358 + version = "0.1.58"
359 + source = "registry+https://github.com/rust-lang/crates.io-index"
360 + checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678"
361 + dependencies = [
362 + "cc",
363 + ]
364 +
365 + [[package]]
366 + name = "cmov"
367 + version = "0.5.0-pre.0"
368 + source = "registry+https://github.com/rust-lang/crates.io-index"
369 + checksum = "5417da527aa9bf6a1e10a781231effd1edd3ee82f27d5f8529ac9b279babce96"
370 +
371 + [[package]]
372 + name = "compact_str"
373 + version = "0.9.0"
374 + source = "registry+https://github.com/rust-lang/crates.io-index"
375 + checksum = "3fdb1325a1cece981e8a296ab8f0f9b63ae357bd0784a9faaf548cc7b480707a"
376 + dependencies = [
377 + "castaway",
378 + "cfg-if",
379 + "itoa",
380 + "rustversion",
381 + "ryu",
382 + "static_assertions",
383 + ]
384 +
385 + [[package]]
386 + name = "const-oid"
387 + version = "0.9.6"
388 + source = "registry+https://github.com/rust-lang/crates.io-index"
389 + checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
390 +
391 + [[package]]
392 + name = "const-oid"
393 + version = "0.10.2"
394 + source = "registry+https://github.com/rust-lang/crates.io-index"
395 + checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
396 +
397 + [[package]]
398 + name = "const-random"
399 + version = "0.1.18"
400 + source = "registry+https://github.com/rust-lang/crates.io-index"
401 + checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359"
402 + dependencies = [
403 + "const-random-macro",
404 + ]
405 +
406 + [[package]]
407 + name = "const-random-macro"
408 + version = "0.1.16"
409 + source = "registry+https://github.com/rust-lang/crates.io-index"
410 + checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e"
411 + dependencies = [
412 + "getrandom 0.2.17",
413 + "once_cell",
414 + "tiny-keccak",
415 + ]
416 +
417 + [[package]]
418 + name = "convert_case"
419 + version = "0.10.0"
420 + source = "registry+https://github.com/rust-lang/crates.io-index"
421 + checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9"
422 + dependencies = [
423 + "unicode-segmentation",
424 + ]
425 +
426 + [[package]]
427 + name = "core-foundation-sys"
428 + version = "0.8.7"
429 + source = "registry+https://github.com/rust-lang/crates.io-index"
430 + checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
431 +
432 + [[package]]
433 + name = "cpufeatures"
434 + version = "0.2.17"
435 + source = "registry+https://github.com/rust-lang/crates.io-index"
436 + checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
437 + dependencies = [
438 + "libc",
439 + ]
440 +
441 + [[package]]
442 + name = "cpufeatures"
443 + version = "0.3.0"
444 + source = "registry+https://github.com/rust-lang/crates.io-index"
445 + checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
446 + dependencies = [
447 + "libc",
448 + ]
449 +
450 + [[package]]
451 + name = "crc32fast"
452 + version = "1.5.0"
453 + source = "registry+https://github.com/rust-lang/crates.io-index"
454 + checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
455 + dependencies = [
456 + "cfg-if",
457 + ]
458 +
459 + [[package]]
460 + name = "crossterm"
461 + version = "0.29.0"
462 + source = "registry+https://github.com/rust-lang/crates.io-index"
463 + checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b"
464 + dependencies = [
465 + "bitflags 2.11.0",
466 + "crossterm_winapi",
467 + "derive_more",
468 + "document-features",
469 + "mio",
470 + "parking_lot",
471 + "rustix",
472 + "signal-hook",
473 + "signal-hook-mio",
474 + "winapi",
475 + ]
476 +
477 + [[package]]
478 + name = "crossterm_winapi"
479 + version = "0.9.1"
480 + source = "registry+https://github.com/rust-lang/crates.io-index"
481 + checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b"
482 + dependencies = [
483 + "winapi",
484 + ]
485 +
486 + [[package]]
487 + name = "crunchy"
488 + version = "0.2.4"
489 + source = "registry+https://github.com/rust-lang/crates.io-index"
490 + checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
491 +
492 + [[package]]
493 + name = "crypto-bigint"
494 + version = "0.5.5"
495 + source = "registry+https://github.com/rust-lang/crates.io-index"
496 + checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76"
497 + dependencies = [
498 + "generic-array 0.14.7",
499 + "rand_core 0.6.4",
500 + "subtle",
Lines truncated
A Cargo.toml +18
@@ -0,0 +1,18 @@
1 + [package]
2 + name = "mnw-cli"
3 + version = "0.1.0"
4 + edition = "2024"
5 +
6 + [dependencies]
7 + russh = "0.58"
8 + russh-sftp = "2.1"
9 + ratatui = "0.30"
10 + crossterm = "0.29"
11 + tokio = { version = "1", features = ["full"] }
12 + reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
13 + serde = { version = "1", features = ["derive"] }
14 + serde_json = "1"
15 + tracing = "0.1"
16 + tracing-subscriber = { version = "0.3", features = ["env-filter"] }
17 + anyhow = "1"
18 + bytes = "1"
@@ -0,0 +1,92 @@
1 + #!/bin/bash
2 + # MNW CLI Deployment Script
3 + # Cross-compiles for x86_64 Linux on macOS, uploads binary, restarts service.
4 + # Run from the mnw-cli directory.
5 + #
6 + # Usage:
7 + # ./deploy/deploy.sh # Full deploy (build + upload + config + restart)
8 + # ./deploy/deploy.sh --quick # Quick deploy (build + upload binary + restart)
9 + # ./deploy/deploy.sh --config # Config only (upload systemd unit)
10 + #
11 + # Prerequisites (one-time):
12 + # brew install zig
13 + # cargo install cargo-zigbuild
14 + # rustup target add x86_64-unknown-linux-gnu
15 +
16 + set -e
17 +
18 + # Configuration
19 + SERVER="root@100.120.174.96"
20 + REMOTE_DIR="/opt/mnw-cli"
21 + STAGING_DIR="/var/lib/mnw-cli/staging"
22 + BINARY_NAME="mnw-cli"
23 + TARGET="x86_64-unknown-linux-gnu"
24 + DEPLOY_DIR="deploy"
25 +
26 + # Check we're in the right directory
27 + if [ ! -f "Cargo.toml" ]; then
28 + echo "Error: Run this script from the mnw-cli/ directory"
29 + exit 1
30 + fi
31 +
32 + upload_config() {
33 + echo "[config] Uploading configuration files..."
34 + scp $DEPLOY_DIR/mnw-cli.service $SERVER:/etc/systemd/system/mnw-cli.service
35 + ssh $SERVER "systemctl daemon-reload"
36 +
37 + # Ensure directories exist
38 + ssh $SERVER "mkdir -p $REMOTE_DIR $STAGING_DIR"
39 + ssh $SERVER "chown makenotwork:makenotwork $STAGING_DIR"
40 +
41 + echo "[config] Done"
42 + }
43 +
44 + build_binary() {
45 + echo "[build] Cross-compiling for $TARGET..."
46 + ulimit -n 65536 2>/dev/null || true
47 + cargo zigbuild --release --target $TARGET
48 + echo "[build] Done: target/$TARGET/release/$BINARY_NAME"
49 + }
50 +
51 + upload_binary() {
52 + echo "[upload] Stopping service and uploading binary..."
53 + ssh $SERVER "systemctl stop mnw-cli || true"
54 + scp target/$TARGET/release/$BINARY_NAME $SERVER:$REMOTE_DIR/$BINARY_NAME
55 + ssh $SERVER "chmod +x $REMOTE_DIR/$BINARY_NAME"
56 + echo "[upload] Done"
57 + }
58 +
59 + restart_app() {
60 + echo "[restart] Restarting mnw-cli..."
61 + ssh $SERVER "systemctl restart mnw-cli"
62 + sleep 1
63 + echo ""
64 + ssh $SERVER "systemctl status mnw-cli --no-pager"
65 + }
66 +
67 + case "${1:-full}" in
68 + --quick)
69 + echo "=== Quick Deploy ==="
70 + build_binary
71 + upload_binary
72 + restart_app
73 + ;;
74 + --config)
75 + echo "=== Config Deploy ==="
76 + upload_config
77 + ;;
78 + full|"")
79 + echo "=== Full Deploy ==="
80 + build_binary
81 + upload_config
82 + upload_binary
83 + restart_app
84 + ;;
85 + *)
86 + echo "Usage: $0 [--quick|--config]"
87 + exit 1
88 + ;;
89 + esac
90 +
91 + echo ""
92 + echo "=== Deploy Complete ==="
@@ -0,0 +1,26 @@
1 + [Unit]
2 + Description=MNW CLI SSH Server
3 + After=network.target
4 + Wants=network-online.target
5 +
6 + [Service]
7 + Type=simple
8 + User=makenotwork
9 + Group=makenotwork
10 + ExecStart=/opt/mnw-cli/mnw-cli
11 + WorkingDirectory=/opt/mnw-cli
12 + EnvironmentFile=/opt/mnw-cli/.env
13 + Restart=on-failure
14 + RestartSec=5
15 + StandardOutput=journal
16 + StandardError=journal
17 +
18 + # Security hardening
19 + NoNewPrivileges=true
20 + ProtectSystem=strict
21 + ProtectHome=true
22 + ReadWritePaths=/opt/mnw-cli /var/lib/mnw-cli
23 + PrivateTmp=true
24 +
25 + [Install]
26 + WantedBy=multi-user.target
A src/api.rs +500
@@ -0,0 +1,957 @@
1 + //! HTTP client for the MNW internal API.
2 +
3 + use serde::{Deserialize, Serialize};
4 +
5 + /// User info returned from the SSH key lookup endpoint.
6 + #[derive(Debug, Clone, Deserialize, Serialize)]
7 + #[allow(dead_code)]
8 + pub struct UserInfo {
9 + pub user_id: String,
10 + pub username: String,
11 + pub display_name: Option<String>,
12 + pub creator_tier: Option<String>,
13 + pub can_create_projects: bool,
14 + pub suspended: bool,
15 + }
16 +
17 + /// A creator's project with item count and revenue.
18 + #[derive(Debug, Clone, Deserialize, Serialize)]
19 + pub struct Project {
20 + pub id: String,
21 + pub slug: String,
22 + pub title: String,
23 + pub project_type: String,
24 + pub is_public: bool,
25 + pub item_count: i64,
26 + pub revenue_cents: i64,
27 + }
28 +
29 + /// An item within a project.
30 + #[derive(Debug, Clone, Deserialize, Serialize)]
31 + #[allow(dead_code)]
32 + pub struct Item {
33 + pub id: String,
34 + pub title: String,
35 + pub item_type: String,
36 + pub price_cents: i32,
37 + pub is_public: bool,
38 + pub sort_order: i32,
39 + }
40 +
41 + /// Period comparison stats for the creator.
42 + #[derive(Debug, Clone, Deserialize, Serialize)]
43 + #[allow(dead_code)]
44 + pub struct CreatorStats {
45 + pub current_revenue_cents: i64,
46 + pub previous_revenue_cents: i64,
47 + pub current_sales: i64,
48 + pub previous_sales: i64,
49 + pub current_followers: i64,
50 + pub previous_followers: i64,
51 + pub total_projects: i64,
52 + pub total_items: i64,
53 + }
54 +
55 + /// Response from the create-item internal endpoint.
56 + #[derive(Debug, Deserialize)]
57 + #[allow(dead_code)]
58 + pub struct ItemCreated {
59 + pub item_id: String,
60 + pub project_id: String,
61 + }
62 +
63 + /// Response from the presign-upload internal endpoint.
64 + #[derive(Debug, Deserialize)]
65 + #[allow(dead_code)]
66 + pub struct PresignResponse {
67 + pub upload_url: String,
68 + pub s3_key: String,
69 + pub expires_in: u64,
70 + pub cache_control: Option<String>,
71 + }
72 +
73 + /// Full item detail returned from the get/update endpoints.
74 + #[derive(Debug, Clone, Deserialize, Serialize)]
75 + #[allow(dead_code)]
76 + pub struct ItemDetail {
77 + pub id: String,
78 + pub title: String,
79 + pub description: Option<String>,
80 + pub price_cents: i32,
81 + pub item_type: String,
82 + pub is_public: bool,
83 + pub slug: String,
84 + pub sort_order: i32,
85 + pub sales_count: i32,
86 + pub download_count: i32,
87 + pub play_count: i32,
88 + pub pwyw_enabled: bool,
89 + pub pwyw_min_cents: Option<i32>,
90 + pub has_audio: bool,
91 + pub has_cover: bool,
92 + pub created_at: String,
93 + pub updated_at: String,
94 + }
95 +
96 + /// A version of an item.
97 + #[derive(Debug, Clone, Deserialize, Serialize)]
98 + #[allow(dead_code)]
99 + pub struct Version {
100 + pub id: String,
101 + pub version_number: String,
102 + pub changelog: Option<String>,
103 + pub file_name: Option<String>,
104 + pub file_size_bytes: Option<i64>,
105 + pub download_count: i32,
106 + pub is_current: bool,
107 + pub created_at: String,
108 + }
109 +
110 + /// A blog post summary.
111 + #[derive(Debug, Clone, Deserialize, Serialize)]
112 + #[allow(dead_code)]
113 + pub struct BlogPost {
114 + pub id: String,
115 + pub title: String,
116 + pub slug: String,
117 + pub is_published: bool,
118 + pub created_at: String,
119 + pub updated_at: String,
120 + }
121 +
122 + /// A promo code.
123 + #[derive(Debug, Clone, Deserialize, Serialize)]
124 + #[allow(dead_code)]
125 + pub struct PromoCode {
126 + pub id: String,
127 + pub code: String,
128 + pub code_purpose: String,
129 + pub discount_type: Option<String>,
130 + pub discount_value: Option<i32>,
131 + pub item_title: Option<String>,
132 + pub project_title: Option<String>,
133 + pub max_uses: Option<i32>,
134 + pub use_count: i32,
135 + pub created_at: String,
136 + }
137 +
138 + /// A license key.
139 + #[derive(Debug, Clone, Deserialize, Serialize)]
140 + #[allow(dead_code)]
141 + pub struct LicenseKey {
142 + pub id: String,
143 + pub key_code: String,
144 + pub activation_count: i32,
145 + pub max_activations: Option<i32>,
146 + pub is_revoked: bool,
147 + pub created_at: String,
148 + }
149 +
150 + /// Response from the storage-info internal endpoint.
151 + #[derive(Debug, Clone, Deserialize, Serialize)]
152 + pub struct StorageInfo {
153 + pub storage_used_bytes: i64,
154 + pub max_storage_bytes: i64,
155 + pub allows_file_uploads: bool,
156 + }
157 +
158 + /// A revenue bucket for analytics timeseries.
159 + #[derive(Debug, Clone, Deserialize, Serialize)]
160 + #[allow(dead_code)]
161 + pub struct AnalyticsBucket {
162 + pub label: String,
163 + pub revenue_cents: i64,
164 + pub sales_count: i64,
165 + }
166 +
167 + /// Per-project revenue summary.
168 + #[derive(Debug, Clone, Deserialize, Serialize)]
169 + #[allow(dead_code)]
170 + pub struct ProjectRevenue {
171 + pub id: String,
172 + pub title: String,
173 + pub revenue_cents: i64,
174 + }
175 +
176 + /// Analytics response with timeseries, comparison, and top projects.
177 + #[derive(Debug, Clone, Deserialize, Serialize)]
178 + pub struct AnalyticsData {
179 + pub buckets: Vec<AnalyticsBucket>,
180 + pub current_revenue_cents: i64,
181 + pub previous_revenue_cents: i64,
182 + pub current_sales: i64,
183 + pub previous_sales: i64,
184 + pub current_followers: i64,
185 + pub previous_followers: i64,
186 + pub top_projects: Vec<ProjectRevenue>,
187 + }
188 +
189 + /// A seller transaction.
190 + #[derive(Debug, Clone, Deserialize, Serialize)]
191 + #[allow(dead_code)]
192 + pub struct Transaction {
193 + pub id: String,
194 + pub item_title: Option<String>,
195 + pub amount_cents: i32,
196 + pub status: String,
197 + pub created_at: String,
198 + pub completed_at: Option<String>,
199 + }
200 +
201 + /// CSV export result.
202 + #[derive(Debug, Clone, Deserialize, Serialize)]
203 + pub struct ExportResult {
204 + pub csv: String,
205 + pub row_count: usize,
206 + }
207 +
208 + /// A registered SSH key.
209 + #[derive(Debug, Clone, Deserialize, Serialize)]
210 + #[allow(dead_code)]
211 + pub struct SshKeyInfo {
212 + pub id: String,
213 + pub label: String,
214 + pub fingerprint: String,
215 + pub created_at: String,
216 + }
217 +
218 + /// Client for calling MNW internal API endpoints.
219 + #[derive(Clone)]
220 + pub struct MnwApiClient {
221 + http: reqwest::Client,
222 + base_url: String,
223 + service_token: String,
224 + }
225 +
226 + impl MnwApiClient {
227 + pub fn new(base_url: String, service_token: String) -> Self {
228 + let http = reqwest::Client::builder()
229 + .timeout(std::time::Duration::from_secs(5))
230 + .build()
231 + .expect("failed to build HTTP client");
232 +
233 + Self {
234 + http,
235 + base_url,
236 + service_token,
237 + }
238 + }
239 +
240 + /// Look up a user by SSH key fingerprint.
241 + /// Returns `Ok(Some(info))` if found, `Ok(None)` if not found.
242 + pub async fn lookup_ssh_key(&self, fingerprint: &str) -> anyhow::Result<Option<UserInfo>> {
243 + let url = format!("{}/api/internal/ssh-key-lookup", self.base_url);
244 + let resp = self
245 + .http
246 + .get(&url)
247 + .bearer_auth(&self.service_token)
248 + .query(&[("fingerprint", fingerprint)])
249 + .send()
250 + .await?;
251 +
252 + if resp.status() == reqwest::StatusCode::NOT_FOUND {
253 + return Ok(None);
254 + }
255 +
256 + if !resp.status().is_success() {
257 + anyhow::bail!("SSH key lookup failed: HTTP {}", resp.status());
258 + }
259 +
260 + let info: UserInfo = resp.json().await?;
261 + Ok(Some(info))
262 + }
263 +
264 + /// Fetch all projects for a creator with item counts and revenue.
265 + pub async fn get_projects(&self, user_id: &str) -> anyhow::Result<Vec<Project>> {
266 + let url = format!("{}/api/internal/creator/projects", self.base_url);
267 + let resp = self
268 + .http
269 + .get(&url)
270 + .bearer_auth(&self.service_token)
271 + .query(&[("user_id", user_id)])
272 + .send()
273 + .await?;
274 +
275 + if !resp.status().is_success() {
276 + anyhow::bail!("get_projects failed: HTTP {}", resp.status());
277 + }
278 +
279 + Ok(resp.json().await?)
280 + }
281 +
282 + /// Fetch items in a project.
283 + pub async fn get_project_items(
284 + &self,
285 + project_id: &str,
286 + user_id: &str,
287 + ) -> anyhow::Result<Vec<Item>> {
288 + let url = format!(
289 + "{}/api/internal/creator/projects/{}/items",
290 + self.base_url, project_id
291 + );
292 + let resp = self
293 + .http
294 + .get(&url)
295 + .bearer_auth(&self.service_token)
296 + .query(&[("user_id", user_id)])
297 + .send()
298 + .await?;
299 +
300 + if !resp.status().is_success() {
301 + anyhow::bail!("get_project_items failed: HTTP {}", resp.status());
302 + }
303 +
304 + Ok(resp.json().await?)
305 + }
306 +
307 + /// Fetch period comparison stats for a creator.
308 + pub async fn get_stats(&self, user_id: &str, range: &str) -> anyhow::Result<CreatorStats> {
309 + let url = format!("{}/api/internal/creator/stats", self.base_url);
310 + let resp = self
311 + .http
312 + .get(&url)
313 + .bearer_auth(&self.service_token)
314 + .query(&[("user_id", user_id), ("range", range)])
315 + .send()
316 + .await?;
317 +
318 + if !resp.status().is_success() {
319 + anyhow::bail!("get_stats failed: HTTP {}", resp.status());
320 + }
321 +
322 + Ok(resp.json().await?)
323 + }
324 +
325 + /// Fetch storage usage and limits for a creator.
326 + pub async fn get_storage_info(&self, user_id: &str) -> anyhow::Result<StorageInfo> {
327 + let url = format!("{}/api/internal/creator/storage", self.base_url);
328 + let resp = self
329 + .http
330 + .get(&url)
331 + .bearer_auth(&self.service_token)
332 + .query(&[("user_id", user_id)])
333 + .send()
334 + .await?;
335 +
336 + if !resp.status().is_success() {
337 + anyhow::bail!("get_storage_info failed: HTTP {}", resp.status());
338 + }
339 +
340 + Ok(resp.json().await?)
341 + }
342 +
343 + /// Create an item in a project.
344 + pub async fn create_item(
345 + &self,
346 + user_id: &str,
347 + project_id: &str,
348 + title: &str,
349 + item_type: &str,
350 + price_cents: i32,
351 + ) -> anyhow::Result<ItemCreated> {
352 + let url = format!("{}/api/internal/creator/items", self.base_url);
353 + let resp = self
354 + .http
355 + .post(&url)
356 + .bearer_auth(&self.service_token)
357 + .json(&serde_json::json!({
358 + "user_id": user_id,
359 + "project_id": project_id,
360 + "title": title,
361 + "item_type": item_type,
362 + "price_cents": price_cents,
363 + }))
364 + .send()
365 + .await?;
366 +
367 + if !resp.status().is_success() {
368 + let status = resp.status();
369 + let body = resp.text().await.unwrap_or_default();
370 + anyhow::bail!("create_item failed: HTTP {} — {}", status, body);
371 + }
372 +
373 + Ok(resp.json().await?)
374 + }
375 +
376 + /// Get a presigned S3 upload URL.
377 + pub async fn presign_upload(
378 + &self,
379 + user_id: &str,
380 + item_id: &str,
381 + file_type: &str,
382 + file_name: &str,
383 + content_type: &str,
384 + ) -> anyhow::Result<PresignResponse> {
385 + let url = format!("{}/api/internal/upload/presign", self.base_url);
386 + let resp = self
387 + .http
388 + .post(&url)
389 + .bearer_auth(&self.service_token)
390 + .json(&serde_json::json!({
391 + "user_id": user_id,
392 + "item_id": item_id,
393 + "file_type": file_type,
394 + "file_name": file_name,
395 + "content_type": content_type,
396 + }))
397 + .send()
398 + .await?;
399 +
400 + if !resp.status().is_success() {
401 + let status = resp.status();
402 + let body = resp.text().await.unwrap_or_default();
403 + anyhow::bail!("presign_upload failed: HTTP {} — {}", status, body);
404 + }
405 +
406 + Ok(resp.json().await?)
407 + }
408 +
409 + /// Confirm a completed S3 upload.
410 + pub async fn confirm_upload(
411 + &self,
412 + user_id: &str,
413 + item_id: &str,
414 + file_type: &str,
415 + s3_key: &str,
416 + ) -> anyhow::Result<bool> {
417 + let url = format!("{}/api/internal/upload/confirm", self.base_url);
418 + let resp = self
419 + .http
420 + .post(&url)
421 + .bearer_auth(&self.service_token)
422 + .json(&serde_json::json!({
423 + "user_id": user_id,
424 + "item_id": item_id,
425 + "file_type": file_type,
426 + "s3_key": s3_key,
427 + }))
428 + .send()
429 + .await?;
430 +
431 + if !resp.status().is_success() {
432 + let status = resp.status();
433 + let body = resp.text().await.unwrap_or_default();
434 + anyhow::bail!("confirm_upload failed: HTTP {} — {}", status, body);
435 + }
436 +
437 + #[derive(Deserialize)]
438 + struct Resp {
439 + success: bool,
440 + }
441 + let r: Resp = resp.json().await?;
442 + Ok(r.success)
443 + }
444 +
445 + /// Fetch full item detail.
446 + pub async fn get_item_detail(
447 + &self,
448 + user_id: &str,
449 + item_id: &str,
450 + ) -> anyhow::Result<ItemDetail> {
451 + let url = format!("{}/api/internal/creator/items/{}", self.base_url, item_id);
452 + let resp = self
453 + .http
454 + .get(&url)
455 + .bearer_auth(&self.service_token)
456 + .query(&[("user_id", user_id)])
457 + .send()
458 + .await?;
459 +
460 + if !resp.status().is_success() {
461 + let status = resp.status();
462 + let body = resp.text().await.unwrap_or_default();
463 + anyhow::bail!("get_item_detail failed: HTTP {} — {}", status, body);
464 + }
465 +
466 + Ok(resp.json().await?)
467 + }
468 +
469 + /// Update item fields. Only non-None fields are changed.
470 + pub async fn update_item(
471 + &self,
472 + user_id: &str,
473 + item_id: &str,
474 + title: Option<&str>,
475 + description: Option<&str>,
476 + price_cents: Option<i32>,
477 + is_public: Option<bool>,
478 + ) -> anyhow::Result<ItemDetail> {
479 + let url = format!("{}/api/internal/creator/items/{}", self.base_url, item_id);
480 + let mut body = serde_json::json!({ "user_id": user_id });
481 + if let Some(t) = title {
482 + body["title"] = serde_json::Value::String(t.to_string());
483 + }
484 + if let Some(d) = description {
485 + body["description"] = serde_json::Value::String(d.to_string());
486 + }
487 + if let Some(p) = price_cents {
488 + body["price_cents"] = serde_json::json!(p);
489 + }
490 + if let Some(v) = is_public {
491 + body["is_public"] = serde_json::json!(v);
492 + }
493 +
494 + let resp = self
495 + .http
496 + .put(&url)
497 + .bearer_auth(&self.service_token)
498 + .json(&body)
499 + .send()
500 + .await?;
Lines truncated
@@ -0,0 +1,317 @@
1 + //! Non-interactive SSH command handlers.
2 + //!
3 + //! When a user runs `ssh cli.makenot.work <command>`, the exec_request handler
4 + //! dispatches to this module. All commands write output to a byte buffer and
5 + //! return it for the SSH channel.
6 +
7 + use crate::api::{MnwApiClient, UserInfo};
8 +
9 + /// Execute a non-interactive command and return the output bytes.
10 + pub async fn execute(
11 + command_line: &str,
12 + user: &UserInfo,
13 + api: &MnwApiClient,
14 + ) -> Vec<u8> {
15 + let parts: Vec<&str> = command_line.trim().split_whitespace().collect();
16 + if parts.is_empty() {
17 + return help_text();
18 + }
19 +
20 + let json = parts.contains(&"--json");
21 + let parts: Vec<&str> = parts.into_iter().filter(|p| *p != "--json").collect();
22 +
23 + match parts[0] {
24 + "projects" => cmd_projects(user, api, json).await,
25 + "analytics" => {
26 + let range = parts
27 + .iter()
28 + .find_map(|p| p.strip_prefix("--range="))
29 + .unwrap_or("30d");
30 + cmd_analytics(user, api, range, json).await
31 + }
32 + "transactions" => cmd_transactions(user, api, json).await,
33 + "export" if parts.get(1) == Some(&"sales") => cmd_export_sales(user, api).await,
34 + "promo" => match parts.get(1).copied() {
35 + Some("list") => cmd_promo_list(user, api, json).await,
36 + Some("create") => {
37 + let code = parts.get(2).unwrap_or(&"");
38 + let pct = parts.get(3).unwrap_or(&"0");
39 + cmd_promo_create(user, api, code, pct).await
40 + }
41 + _ => b"Usage: promo list | promo create CODE DISCOUNT_PCT\r\n".to_vec(),
42 + },
43 + "blog" => match parts.get(1).copied() {
44 + Some("list") => {
45 + let slug = parts.get(2).unwrap_or(&"");
46 + cmd_blog_list(user, api, slug, json).await
47 + }
48 + _ => b"Usage: blog list <project-slug>\r\n".to_vec(),
49 + },
50 + "help" | "--help" | "-h" => help_text(),
51 + other => format!("Unknown command: {other}\r\nRun without arguments for usage help.\r\n")
52 + .into_bytes(),
53 + }
54 + }
55 +
56 + async fn cmd_projects(user: &UserInfo, api: &MnwApiClient, json: bool) -> Vec<u8> {
57 + match api.get_projects(&user.user_id).await {
58 + Ok(projects) => {
59 + if json {
60 + return serde_json::to_vec_pretty(&projects).unwrap_or_default();
61 + }
62 + if projects.is_empty() {
63 + return b"No projects.\r\n".to_vec();
64 + }
65 + let mut out = format!(
66 + "{:<30} {:<12} {:<8} {:<6} {:<10}\r\n",
67 + "Title", "Type", "Status", "Items", "Revenue"
68 + );
69 + out.push_str(&"-".repeat(70));
70 + out.push_str("\r\n");
71 + for p in &projects {
72 + let status = if p.is_public { "public" } else { "draft" };
73 + let revenue = format_cents(p.revenue_cents);
74 + out.push_str(&format!(
75 + "{:<30} {:<12} {:<8} {:<6} {:<10}\r\n",
76 + truncate(&p.title, 29),
77 + p.project_type,
78 + status,
79 + p.item_count,
80 + revenue,
81 + ));
82 + }
83 + out.into_bytes()
84 + }
85 + Err(e) => format!("Error: {e}\r\n").into_bytes(),
86 + }
87 + }
88 +
89 + async fn cmd_analytics(
90 + user: &UserInfo,
91 + api: &MnwApiClient,
92 + range: &str,
93 + json: bool,
94 + ) -> Vec<u8> {
95 + match api.get_analytics(&user.user_id, range).await {
96 + Ok(data) => {
97 + if json {
98 + return serde_json::to_vec_pretty(&data).unwrap_or_default();
99 + }
100 +
101 + let mut out = String::new();
102 + out.push_str(&format!("Analytics ({range})\r\n\r\n"));
103 +
104 + let rev = format_cents(data.current_revenue_cents);
105 + let prev_rev = format_cents(data.previous_revenue_cents);
106 + out.push_str(&format!("Revenue: {rev} (prev: {prev_rev})\r\n"));
107 + out.push_str(&format!(
108 + "Sales: {} (prev: {})\r\n",
109 + data.current_sales, data.previous_sales
110 + ));
111 + out.push_str(&format!(
112 + "Followers: {} (prev: {})\r\n",
113 + data.current_followers, data.previous_followers
114 + ));
115 +
116 + if !data.top_projects.is_empty() {
117 + out.push_str("\r\nTop Projects:\r\n");
118 + for p in &data.top_projects {
119 + out.push_str(&format!(
120 + " {:<30} {}\r\n",
121 + truncate(&p.title, 29),
122 + format_cents(p.revenue_cents)
123 + ));
124 + }
125 + }
126 +
127 + out.into_bytes()
128 + }
129 + Err(e) => format!("Error: {e}\r\n").into_bytes(),
130 + }
131 + }
132 +
133 + async fn cmd_transactions(user: &UserInfo, api: &MnwApiClient, json: bool) -> Vec<u8> {
134 + match api.get_transactions(&user.user_id).await {
135 + Ok(txs) => {
136 + if json {
137 + return serde_json::to_vec_pretty(&txs).unwrap_or_default();
138 + }
139 + if txs.is_empty() {
140 + return b"No transactions.\r\n".to_vec();
141 + }
142 + let mut out = format!(
143 + "{:<30} {:<10} {:<12} {:<12}\r\n",
144 + "Item", "Amount", "Status", "Date"
145 + );
146 + out.push_str(&"-".repeat(66));
147 + out.push_str("\r\n");
148 + for tx in &txs {
149 + let title = tx.item_title.as_deref().unwrap_or("--");
150 + let amount = format_cents(tx.amount_cents as i64);
151 + let date = tx.created_at.get(..10).unwrap_or(&tx.created_at);
152 + out.push_str(&format!(
153 + "{:<30} {:<10} {:<12} {:<12}\r\n",
154 + truncate(title, 29),
155 + amount,
156 + tx.status,
157 + date,
158 + ));
159 + }
160 + out.into_bytes()
161 + }
162 + Err(e) => format!("Error: {e}\r\n").into_bytes(),
163 + }
164 + }
165 +
166 + async fn cmd_export_sales(user: &UserInfo, api: &MnwApiClient) -> Vec<u8> {
167 + match api.export_sales_csv(&user.user_id).await {
168 + Ok(result) => result.csv.into_bytes(),
169 + Err(e) => format!("Error: {e}\r\n").into_bytes(),
170 + }
171 + }
172 +
173 + async fn cmd_promo_list(user: &UserInfo, api: &MnwApiClient, json: bool) -> Vec<u8> {
174 + match api.list_promo_codes(&user.user_id).await {
175 + Ok(codes) => {
176 + if json {
177 + return serde_json::to_vec_pretty(&codes).unwrap_or_default();
178 + }
179 + if codes.is_empty() {
180 + return b"No promo codes.\r\n".to_vec();
181 + }
182 + let mut out = format!(
183 + "{:<20} {:<12} {:<20} {:<10}\r\n",
184 + "Code", "Discount", "Scope", "Uses"
185 + );
186 + out.push_str(&"-".repeat(64));
187 + out.push_str("\r\n");
188 + for c in &codes {
189 + let discount = match (c.discount_type.as_deref(), c.discount_value) {
190 + (Some("percentage"), Some(v)) => format!("{}% off", v),
191 + (Some("fixed"), Some(v)) => format!("${}.{:02} off", v / 100, v % 100),
192 + _ => "Free".to_string(),
193 + };
194 + let scope = c
195 + .item_title
196 + .as_deref()
197 + .or(c.project_title.as_deref())
198 + .unwrap_or("All items");
199 + let uses = match c.max_uses {
200 + Some(max) => format!("{}/{}", c.use_count, max),
201 + None => c.use_count.to_string(),
202 + };
203 + out.push_str(&format!(
204 + "{:<20} {:<12} {:<20} {:<10}\r\n",
205 + truncate(&c.code, 19),
206 + discount,
207 + truncate(scope, 19),
208 + uses,
209 + ));
210 + }
211 + out.into_bytes()
212 + }
213 + Err(e) => format!("Error: {e}\r\n").into_bytes(),
214 + }
215 + }
216 +
217 + async fn cmd_promo_create(
218 + user: &UserInfo,
219 + api: &MnwApiClient,
220 + code: &str,
221 + pct: &str,
222 + ) -> Vec<u8> {
223 + if code.is_empty() {
224 + return b"Usage: promo create CODE DISCOUNT_PCT\r\n".to_vec();
225 + }
226 + let discount: i32 = pct.parse().unwrap_or(0);
227 + match api
228 + .create_promo_code(&user.user_id, code, "percentage", discount, None, None)
229 + .await
230 + {
231 + Ok(_) => format!("Created promo code: {code} ({discount}% off)\r\n").into_bytes(),
232 + Err(e) => format!("Error: {e}\r\n").into_bytes(),
233 + }
234 + }
235 +
236 + async fn cmd_blog_list(
237 + user: &UserInfo,
238 + api: &MnwApiClient,
239 + slug: &str,
240 + json: bool,
241 + ) -> Vec<u8> {
242 + if slug.is_empty() {
243 + return b"Usage: blog list <project-slug>\r\n".to_vec();
244 + }
245 +
246 + // Find the project by slug
247 + let projects = match api.get_projects(&user.user_id).await {
248 + Ok(p) => p,
249 + Err(e) => return format!("Error: {e}\r\n").into_bytes(),
250 + };
251 +
252 + let Some(project) = projects.iter().find(|p| p.slug == slug) else {
253 + return format!("Project not found: {slug}\r\n").into_bytes();
254 + };
255 +
256 + match api.list_blog_posts(&user.user_id, &project.id).await {
257 + Ok(posts) => {
258 + if json {
259 + return serde_json::to_vec_pretty(&posts).unwrap_or_default();
260 + }
261 + if posts.is_empty() {
262 + return b"No blog posts.\r\n".to_vec();
263 + }
264 + let mut out = format!(
265 + "{:<30} {:<20} {:<10} {:<12}\r\n",
266 + "Title", "Slug", "Status", "Created"
267 + );
268 + out.push_str(&"-".repeat(74));
269 + out.push_str("\r\n");
270 + for p in &posts {
271 + let status = if p.is_published { "published" } else { "draft" };
272 + let date = p.created_at.get(..10).unwrap_or(&p.created_at);
273 + out.push_str(&format!(
274 + "{:<30} {:<20} {:<10} {:<12}\r\n",
275 + truncate(&p.title, 29),
276 + truncate(&p.slug, 19),
277 + status,
278 + date,
279 + ));
280 + }
281 + out.into_bytes()
282 + }
283 + Err(e) => format!("Error: {e}\r\n").into_bytes(),
284 + }
285 + }
286 +
287 + fn format_cents(cents: i64) -> String {
288 + if cents == 0 {
289 + "$0".to_string()
290 + } else {
291 + format!("${}.{:02}", cents / 100, cents.abs() % 100)
292 + }
293 + }
294 +
295 + fn help_text() -> Vec<u8> {
296 + b"Usage: ssh cli.makenot.work <command>\r\n\
297 + \r\n\
298 + Commands:\r\n\
299 + \x20 projects List your projects\r\n\
300 + \x20 analytics [--range=N] Revenue stats (7d/30d/90d/all)\r\n\
301 + \x20 transactions Recent transactions\r\n\
302 + \x20 export sales Export sales as CSV\r\n\
303 + \x20 promo list List promo codes\r\n\
304 + \x20 promo create CODE PCT Create a promo code\r\n\
305 + \x20 blog list SLUG List blog posts for project\r\n\
306 + \r\n\
307 + Add --json to any command for machine-readable output.\r\n"
308 + .to_vec()
309 + }
310 +
311 + fn truncate(s: &str, max_len: usize) -> &str {
312 + if s.len() <= max_len {
313 + s
314 + } else {
315 + &s[..max_len]
316 + }
317 + }
@@ -0,0 +1,49 @@
1 + //! Configuration loaded from environment variables.
2 +
3 + use std::path::PathBuf;
4 +
5 + /// Application configuration.
6 + pub struct Config {
7 + /// Port to listen on for SSH connections.
8 + pub port: u16,
9 + /// MNW API base URL (e.g., "http://localhost:3000").
10 + pub api_url: String,
11 + /// Service token for authenticating with MNW internal API.
12 + pub service_token: String,
13 + /// Path to the ed25519 host key file.
14 + pub host_key_path: PathBuf,
15 + /// Base directory for staging uploaded files.
16 + pub staging_dir: PathBuf,
17 + }
18 +
19 + impl Config {
20 + /// Load configuration from environment variables.
21 + pub fn from_env() -> anyhow::Result<Self> {
22 + let port: u16 = std::env::var("SSH_PORT")
23 + .unwrap_or_else(|_| "2222".to_string())
24 + .parse()
25 + .map_err(|_| anyhow::anyhow!("Invalid SSH_PORT"))?;
26 +
27 + let api_url = std::env::var("MNW_API_URL")
28 + .unwrap_or_else(|_| "http://localhost:3000".to_string());
29 +
30 + let service_token = std::env::var("MNW_SERVICE_TOKEN")
31 + .map_err(|_| anyhow::anyhow!("MNW_SERVICE_TOKEN is required"))?;
32 +
33 + let host_key_path = std::env::var("SSH_HOST_KEY")
34 + .unwrap_or_else(|_| "host_ed25519".to_string())
35 + .into();
36 +
37 + let staging_dir = std::env::var("STAGING_DIR")
38 + .unwrap_or_else(|_| "/var/lib/mnw-cli/staging".to_string())
39 + .into();
40 +
41 + Ok(Config {
42 + port,
43 + api_url,
44 + service_token,
45 + host_key_path,
46 + staging_dir,
47 + })
48 + }
49 + }
A src/main.rs +129
@@ -0,0 +1,129 @@
1 + //! MNW CLI — SSH-based TUI for the Makenot.work creator platform.
2 + //!
3 + //! Runs a russh SSH server that authenticates creators by their registered
4 + //! SSH public keys (via the MNW internal API) and presents a ratatui TUI
5 + //! for managing projects, items, uploads, and analytics.
6 +
7 + mod api;
8 + mod commands;
9 + mod config;
10 + mod ssh;
11 + mod staging;
12 + mod tui;
13 +
14 + use std::sync::Arc;
15 +
16 + use russh::keys::{self, Algorithm, PrivateKey, ssh_key};
17 + use russh::server::Server as _;
18 + use russh::MethodKind;
19 + use tokio::signal;
20 + use tracing_subscriber::EnvFilter;
21 +
22 + #[tokio::main]
23 + async fn main() -> anyhow::Result<()> {
24 + tracing_subscriber::fmt()
25 + .with_env_filter(EnvFilter::from_default_env().add_directive("mnw_cli=info".parse()?))
26 + .init();
27 +
28 + let config = config::Config::from_env()?;
29 +
30 + // Ensure staging base directory exists
31 + std::fs::create_dir_all(&config.staging_dir)?;
32 + tracing::info!(staging_dir = %config.staging_dir.display(), "staging directory ready");
33 +
34 + // Spawn hourly cleanup task for stale staging files (24h TTL)
35 + let cleanup_dir = config.staging_dir.clone();
36 + tokio::spawn(async move {
37 + let ttl = std::time::Duration::from_secs(24 * 60 * 60);
38 + let mut interval = tokio::time::interval(std::time::Duration::from_secs(60 * 60));
39 + loop {
40 + interval.tick().await;
41 + staging::cleanup_stale(&cleanup_dir, ttl).await;
42 + }
43 + });
44 +
45 + // Load or generate host key
46 + let host_key = load_or_generate_host_key(&config.host_key_path)?;
47 +
48 + tracing::info!(
49 + port = config.port,
50 + api_url = %config.api_url,
51 + host_key = %config.host_key_path.display(),
52 + "starting MNW CLI SSH server"
53 + );
54 +
55 + let mut methods = russh::MethodSet::empty();
56 + methods.push(MethodKind::PublicKey);
57 +
58 + let ssh_config = russh::server::Config {
59 + methods,
60 + keys: vec![host_key],
61 + auth_rejection_time: std::time::Duration::from_secs(1),
62 + auth_rejection_time_initial: Some(std::time::Duration::from_millis(0)),
63 + ..Default::default()
64 + };
65 +
66 + let staging_dir = Arc::new(config.staging_dir);
67 + let api_client = api::MnwApiClient::new(config.api_url, config.service_token);
68 + let mut server = ssh::MnwServer::new(api_client, staging_dir);
69 +
70 + let addr = format!("0.0.0.0:{}", config.port);
71 + tracing::info!(%addr, "listening for SSH connections");
72 +
73 + // Run SSH server with graceful shutdown on SIGTERM/SIGINT
74 + tokio::select! {
75 + result = server.run_on_address(Arc::new(ssh_config), addr) => {
76 + result?;
77 + }
78 + _ = shutdown_signal() => {
79 + tracing::info!("shutdown signal received, stopping");
80 + }
81 + }
82 +
83 + tracing::info!("MNW CLI server stopped");
84 + Ok(())
85 + }
86 +
87 + async fn shutdown_signal() {
88 + let ctrl_c = signal::ctrl_c();
89 + #[cfg(unix)]
90 + let mut sigterm = signal::unix::signal(signal::unix::SignalKind::terminate())
91 + .expect("failed to register SIGTERM handler");
92 + #[cfg(unix)]
93 + tokio::select! {
94 + _ = ctrl_c => {}
95 + _ = sigterm.recv() => {}
96 + }
97 + #[cfg(not(unix))]
98 + ctrl_c.await.ok();
99 + }
100 +
101 + /// Load an ed25519 host key from disk, or generate and save one if it doesn't exist.
102 + fn load_or_generate_host_key(path: &std::path::Path) -> anyhow::Result<PrivateKey> {
103 + if path.exists() {
104 + tracing::info!(path = %path.display(), "loading host key");
105 + let key = keys::load_secret_key(path, None)?;
106 + Ok(key)
107 + } else {
108 + tracing::info!(path = %path.display(), "generating new ed25519 host key");
109 + // Use the rand_core OsRng re-exported through russh's ssh_key dependency
110 + // to avoid version conflicts with the standalone rand crate.
111 + let key = ssh_key::private::PrivateKey::random(
112 + &mut keys::signature::rand_core::OsRng,
113 + Algorithm::Ed25519,
114 + )?;
115 + // Save to disk in OpenSSH format
116 + let pem = key.to_openssh(ssh_key::LineEnding::LF)?;
117 + if let Some(parent) = path.parent() {
118 + std::fs::create_dir_all(parent)?;
119 + }
120 + std::fs::write(path, pem.as_bytes())?;
121 + #[cfg(unix)]
122 + {
123 + use std::os::unix::fs::PermissionsExt;
124 + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
125 + }
126 + tracing::info!(path = %path.display(), "host key saved");
127 + Ok(key.into())
128 + }
129 + }
@@ -0,0 +1,290 @@
1 + //! SSH session handler: authentication, PTY, shell, SFTP, and input dispatch.
2 +
3 + use std::collections::HashMap;
4 + use std::net::SocketAddr;
5 + use std::path::PathBuf;
6 + use std::sync::Arc;
7 +
8 + use russh::keys::{HashAlg, PublicKey};
9 + use russh::server::{Auth, Msg, Session};
10 + use russh::{Channel, ChannelId};
11 + use tokio::sync::Mutex;
12 +
13 + use crate::api::{MnwApiClient, UserInfo};
14 + use crate::ssh::sftp::SftpSession;
15 + use crate::ssh::terminal::TerminalHandle;
16 + use crate::staging;
17 + use crate::tui;
18 +
19 + /// Per-connection handler. Created by `MnwServer::new_client()`.
20 + pub struct MnwHandler {
21 + api: MnwApiClient,
22 + peer_addr: Option<SocketAddr>,
23 + staging_dir: Arc<PathBuf>,
24 + /// Populated after successful auth.
25 + user: Option<UserInfo>,
26 + /// Terminal dimensions (cols, rows).
27 + term_size: (u16, u16),
28 + /// TUI application handle for forwarding keypresses.
29 + app: Option<tui::AppHandle>,
30 + /// Channels stored between open and shell/subsystem request.
31 + channels: Arc<Mutex<HashMap<ChannelId, Channel<Msg>>>>,
32 + }
33 +
34 + impl MnwHandler {
35 + pub fn new(api: MnwApiClient, peer_addr: Option<SocketAddr>, staging_dir: Arc<PathBuf>) -> Self {
36 + Self {
37 + api,
38 + peer_addr,
39 + staging_dir,
40 + user: None,
41 + term_size: (80, 24),
42 + app: None,
43 + channels: Arc::new(Mutex::new(HashMap::new())),
44 + }
45 + }
46 + }
47 +
48 + impl russh::server::Handler for MnwHandler {
49 + type Error = anyhow::Error;
50 +
51 + async fn auth_publickey_offered(
52 + &mut self,
53 + _user: &str,
54 + key: &PublicKey,
55 + ) -> Result<Auth, Self::Error> {
56 + let fingerprint = key.fingerprint(HashAlg::Sha256).to_string();
57 + tracing::debug!(%fingerprint, peer = ?self.peer_addr, "key offered");
58 +
59 + match self.api.lookup_ssh_key(&fingerprint).await {
60 + Ok(Some(info)) => {
61 + if info.suspended {
62 + tracing::warn!(user = %info.username, "suspended user attempted SSH login");
63 + return Ok(Auth::Reject {
64 + proceed_with_methods: None,
65 + partial_success: false,
66 + });
67 + }
68 + self.user = Some(info);
69 + Ok(Auth::Accept)
70 + }
71 + Ok(None) => {
72 + tracing::debug!(%fingerprint, "key not found");
73 + Ok(Auth::Reject {
74 + proceed_with_methods: None,
75 + partial_success: false,
76 + })
77 + }
78 + Err(e) => {
79 + tracing::error!(error = ?e, "SSH key lookup failed");
80 + Ok(Auth::Reject {
81 + proceed_with_methods: None,
82 + partial_success: false,
83 + })
84 + }
85 + }
86 + }
87 +
88 + async fn auth_publickey(
89 + &mut self,
90 + _user: &str,
91 + _key: &PublicKey,
92 + ) -> Result<Auth, Self::Error> {
93 + // If auth_publickey_offered accepted, the user is already stored.
94 + if self.user.is_some() {
95 + Ok(Auth::Accept)
96 + } else {
97 + Ok(Auth::Reject {
98 + proceed_with_methods: None,
99 + partial_success: false,
100 + })
101 + }
102 + }
103 +
104 + async fn channel_open_session(
105 + &mut self,
106 + channel: Channel<Msg>,
107 + _session: &mut Session,
108 + ) -> Result<bool, Self::Error> {
109 + let channel_id = channel.id();
110 + tracing::debug!(channel = %channel_id, "session channel opened");
111 + // Store channel for later consumption by shell_request or subsystem_request
112 + self.channels.lock().await.insert(channel_id, channel);
113 + Ok(true)
114 + }
115 +
116 + async fn pty_request(
117 + &mut self,
118 + _channel: ChannelId,
119 + _term: &str,
120 + col_width: u32,
121 + row_height: u32,
122 + _pix_width: u32,
123 + _pix_height: u32,
124 + _modes: &[(russh::Pty, u32)],
125 + _session: &mut Session,
126 + ) -> Result<(), Self::Error> {
127 + self.term_size = (col_width as u16, row_height as u16);
128 + tracing::debug!(cols = col_width, rows = row_height, "PTY requested");
129 + Ok(())
130 + }
131 +
132 + async fn shell_request(
133 + &mut self,
134 + channel: ChannelId,
135 + session: &mut Session,
136 + ) -> Result<(), Self::Error> {
137 + let Some(ref user) = self.user else {
138 + tracing::warn!("shell_request without authenticated user");
139 + session.close(channel)?;
140 + return Ok(());
141 + };
142 +
143 + tracing::info!(user = %user.username, "launching TUI");
144 +
145 + let handle = session.handle();
146 + let terminal_handle = TerminalHandle::new(handle.clone(), channel);
147 + let (cols, rows) = self.term_size;
148 +
149 + let user_clone = user.clone();
150 + let staging_dir = staging::user_staging_dir(&self.staging_dir, &user.user_id);
151 + let app_handle = tui::launch(
152 + terminal_handle,
153 + user_clone,
154 + cols,
155 + rows,
156 + handle,
157 + channel,
158 + self.api.clone(),
159 + staging_dir,
160 + )?;
161 + self.app = Some(app_handle);
162 +
163 + Ok(())
164 + }
165 +
166 + async fn subsystem_request(
167 + &mut self,
168 + channel_id: ChannelId,
169 + name: &str,
170 + session: &mut Session,
171 + ) -> Result<(), Self::Error> {
172 + if name != "sftp" {
173 + tracing::debug!(subsystem = name, "unsupported subsystem requested");
174 + session.close(channel_id)?;
175 + return Ok(());
176 + }
177 +
178 + let Some(ref user) = self.user else {
179 + tracing::warn!("subsystem_request without authenticated user");
180 + session.close(channel_id)?;
181 + return Ok(());
182 + };
183 +
184 + // Take the stored channel for this ID
185 + let channel = self
186 + .channels
187 + .lock()
188 + .await
189 + .remove(&channel_id);
190 +
191 + let Some(channel) = channel else {
192 + tracing::error!("no stored channel for SFTP subsystem");
193 + session.close(channel_id)?;
194 + return Ok(());
195 + };
196 +
197 + let user_staging = staging::user_staging_dir(&self.staging_dir, &user.user_id);
198 + let sftp_session = SftpSession::new(
199 + user.user_id.clone(),
200 + user.creator_tier.clone(),
201 + user_staging,
202 + );
203 +
204 + tracing::info!(user = %user.username, "starting SFTP session");
205 +
206 + let stream = channel.into_stream();
207 + tokio::spawn(async move {
208 + russh_sftp::server::run(stream, sftp_session).await;
209 + });
210 +
211 + Ok(())
212 + }
213 +
214 + async fn exec_request(
215 + &mut self,
216 + channel: ChannelId,
217 + data: &[u8],
218 + session: &mut Session,
219 + ) -> Result<(), Self::Error> {
220 + let command_line = String::from_utf8_lossy(data);
221 + let handle = session.handle();
222 +
223 + // Check if this looks like a legacy SCP transfer
224 + if command_line.starts_with("scp ") {
225 + let msg: &[u8] =
226 + b"Use scp (not scp -O) or sftp to upload files to cli.makenot.work\r\n";
227 + let bytes = bytes::Bytes::copy_from_slice(msg);
228 + tokio::spawn(async move {
229 + let _ = handle.data(channel, bytes).await;
230 + let _ = handle.close(channel).await;
231 + });
232 + return Ok(());
233 + }
234 +
235 + // Execute the command
236 + let Some(ref user) = self.user else {
237 + let _ = handle.close(channel).await;
238 + return Ok(());
239 + };
240 +
241 + let user = user.clone();
242 + let api = self.api.clone();
243 + let cmd = command_line.to_string();
244 + tracing::info!(user = %user.username, command = %cmd, "exec command");
245 +
246 + tokio::spawn(async move {
247 + let output = crate::commands::execute(&cmd, &user, &api).await;
248 + let bytes = bytes::Bytes::from(output);
249 + let _ = handle.data(channel, bytes).await;
250 + let _ = handle.close(channel).await;
251 + });
252 +
253 + Ok(())
254 + }
255 +
256 + async fn data(
257 + &mut self,
258 + _channel: ChannelId,
259 + data: &[u8],
260 + _session: &mut Session,
261 + ) -> Result<(), Self::Error> {
262 + if let Some(ref app) = self.app {
263 + app.send_input(data).await;
264 + }
265 + Ok(())
266 + }
267 +
268 + async fn window_change_request(
269 + &mut self,
270 + _channel: ChannelId,
271 + col_width: u32,
272 + row_height: u32,
273 + _pix_width: u32,
274 + _pix_height: u32,
275 + _session: &mut Session,
276 + ) -> Result<(), Self::Error> {
277 + self.term_size = (col_width as u16, row_height as u16);
278 + if let Some(ref app) = self.app {
279 + app.send_resize(col_width as u16, row_height as u16).await;
280 + }
281 + Ok(())
282 + }
283 +
284 + async fn auth_succeeded(&mut self, _session: &mut Session) -> Result<(), Self::Error> {
285 + if let Some(ref user) = self.user {
286 + tracing::info!(user = %user.username, peer = ?self.peer_addr, "authenticated");
287 + }
288 + Ok(())
289 + }
290 + }
@@ -0,0 +1,32 @@
1 + //! SSH server implementation using russh.
2 +
3 + pub mod handler;
4 + pub mod sftp;
5 + pub mod terminal;
6 +
7 + use std::net::SocketAddr;
8 + use std::path::PathBuf;
9 + use std::sync::Arc;
10 +
11 + use crate::api::MnwApiClient;
12 +
13 + /// SSH server that spawns a new handler per connection.
14 + pub struct MnwServer {
15 + api: MnwApiClient,
16 + staging_dir: Arc<PathBuf>,
17 + }
18 +
19 + impl MnwServer {
20 + pub fn new(api: MnwApiClient, staging_dir: Arc<PathBuf>) -> Self {
21 + Self { api, staging_dir }
22 + }
23 + }
24 +
25 + impl russh::server::Server for MnwServer {
26 + type Handler = handler::MnwHandler;
27 +
28 + fn new_client(&mut self, peer_addr: Option<SocketAddr>) -> Self::Handler {
29 + tracing::info!(?peer_addr, "new SSH connection");
30 + handler::MnwHandler::new(self.api.clone(), peer_addr, Arc::clone(&self.staging_dir))
31 + }
32 + }
@@ -0,0 +1,426 @@
1 + //! SFTP subsystem handler for file uploads.
2 + //!
3 + //! Presents a virtual filesystem with a single `/upload/` directory.
4 + //! Files written here land in the staging directory on disk, from where
5 + //! the TUI publish flow sends them to S3.
6 +
7 + use std::collections::HashMap;
8 + use std::path::PathBuf;
9 +
10 + use russh_sftp::protocol::{
11 + Attrs, Data, File, FileAttributes, Handle, Name, OpenFlags, Status, StatusCode, Version,
12 + };
13 +
14 + use crate::staging::{self, is_allowed_extension, sanitize_filename, STAGING_QUOTA_BYTES};
15 +
16 + /// SFTP session handler for a single authenticated user.
17 + pub struct SftpSession {
18 + user_id: String,
19 + creator_tier: Option<String>,
20 + staging_dir: PathBuf,
21 + open_files: HashMap<String, OpenFile>,
22 + dir_handles: HashMap<String, bool>, // handle -> already_read
23 + next_handle: u64,
24 + }
25 +
26 + struct OpenFile {
27 + path: PathBuf,
28 + file: tokio::fs::File,
29 + }
30 +
31 + impl SftpSession {
32 + pub fn new(user_id: String, creator_tier: Option<String>, staging_dir: PathBuf) -> Self {
33 + Self {
34 + user_id,
35 + creator_tier,
36 + staging_dir,
37 + open_files: HashMap::new(),
38 + dir_handles: HashMap::new(),
39 + next_handle: 1,
40 + }
41 + }
42 +
43 + fn alloc_handle(&mut self) -> String {
44 + let h = self.next_handle;
45 + self.next_handle += 1;
46 + format!("h{}", h)
47 + }
48 +
49 + fn ok_status(&self, id: u32) -> Status {
50 + Status {
51 + id,
52 + status_code: StatusCode::Ok,
53 + error_message: String::new(),
54 + language_tag: String::new(),
55 + }
56 + }
57 +
58 + fn is_basic_tier(&self) -> bool {
59 + self.creator_tier.as_deref() == Some("basic")
60 + }
61 +
62 + fn is_upload_path(path: &str) -> bool {
63 + let normalized = path.trim_matches('/');
64 + normalized == "upload" || normalized.is_empty() || normalized == "."
65 + }
66 +
67 + fn extract_filename(path: &str) -> Option<&str> {
68 + let normalized = path.trim_start_matches('/');
69 + normalized.strip_prefix("upload/").or_else(|| {
70 + // Direct filename without upload/ prefix
71 + if !normalized.contains('/') && normalized != "upload" && !normalized.is_empty() {
72 + Some(normalized)
73 + } else {
74 + None
75 + }
76 + })
77 + }
78 + }
79 +
80 + impl russh_sftp::server::Handler for SftpSession {
81 + type Error = StatusCode;
82 +
83 + fn unimplemented(&self) -> Self::Error {
84 + StatusCode::OpUnsupported
85 + }
86 +
87 + async fn init(
88 + &mut self,
89 + version: u32,
90 + _extensions: HashMap<String, String>,
91 + ) -> Result<Version, Self::Error> {
92 + tracing::debug!(user = %self.user_id, sftp_version = version, "SFTP session initialized");
93 +
94 + // Ensure staging dir exists
95 + if let Err(e) = tokio::fs::create_dir_all(&self.staging_dir).await {
96 + tracing::error!(error = ?e, "failed to create staging dir");
97 + return Err(StatusCode::Failure);
98 + }
99 +
100 + Ok(Version::new())
101 + }
102 +
103 + async fn realpath(&mut self, id: u32, _path: String) -> Result<Name, Self::Error> {
104 + Ok(Name {
105 + id,
106 + files: vec![File::dummy("/upload")],
107 + })
108 + }
109 +
110 + async fn opendir(&mut self, id: u32, path: String) -> Result<Handle, Self::Error> {
111 + if !Self::is_upload_path(&path) {
112 + return Err(StatusCode::NoSuchFile);
113 + }
114 +
115 + let handle = self.alloc_handle();
116 + self.dir_handles.insert(handle.clone(), false);
117 +
118 + Ok(Handle { id, handle })
119 + }
120 +
121 + async fn readdir(&mut self, id: u32, handle: String) -> Result<Name, Self::Error> {
122 + let already_read = self
123 + .dir_handles
124 + .get_mut(&handle)
125 + .ok_or(StatusCode::Failure)?;
126 +
127 + if *already_read {
128 + return Err(StatusCode::Eof);
129 + }
130 + *already_read = true;
131 +
132 + let staged = staging::list_staged_files(&self.staging_dir).await;
133 +
134 + let files: Vec<File> = staged
135 + .into_iter()
136 + .map(|sf| {
137 + let mut attrs = FileAttributes::empty();
138 + attrs.set_regular(true);
139 + attrs.size = Some(sf.size);
140 + if let Ok(dur) = sf
141 + .modified
142 + .duration_since(std::time::SystemTime::UNIX_EPOCH)
143 + {
144 + attrs.mtime = Some(dur.as_secs() as u32);
145 + }
146 + File::new(sf.filename, attrs)
147 + })
148 + .collect();
149 +
150 + Ok(Name { id, files })
151 + }
152 +
153 + async fn stat(&mut self, id: u32, path: String) -> Result<Attrs, Self::Error> {
154 + if Self::is_upload_path(&path) {
155 + let mut attrs = FileAttributes::empty();
156 + attrs.set_dir(true);
157 + attrs.permissions = Some(0o755);
158 + return Ok(Attrs { id, attrs });
159 + }
160 +
161 + if let Some(filename) = Self::extract_filename(&path) {
162 + let file_path = self.staging_dir.join(sanitize_filename(filename));
163 + if let Ok(metadata) = tokio::fs::metadata(&file_path).await {
164 + let attrs = FileAttributes::from(&metadata);
165 + return Ok(Attrs { id, attrs });
166 + }
167 + }
168 +
169 + Err(StatusCode::NoSuchFile)
170 + }
171 +
172 + async fn lstat(&mut self, id: u32, path: String) -> Result<Attrs, Self::Error> {
173 + self.stat(id, path).await
174 + }
175 +
176 + async fn fstat(&mut self, id: u32, handle: String) -> Result<Attrs, Self::Error> {
177 + if self.dir_handles.contains_key(&handle) {
178 + let mut attrs = FileAttributes::empty();
179 + attrs.set_dir(true);
180 + attrs.permissions = Some(0o755);
181 + return Ok(Attrs { id, attrs });
182 + }
183 +
184 + if let Some(of) = self.open_files.get(&handle) {
185 + if let Ok(metadata) = of.file.metadata().await {
186 + let attrs = FileAttributes::from(&metadata);
187 + return Ok(Attrs { id, attrs });
188 + }
189 + }
190 +
191 + Err(StatusCode::Failure)
192 + }
193 +
194 + async fn open(
195 + &mut self,
196 + id: u32,
197 + filename: String,
198 + pflags: OpenFlags,
199 + _attrs: FileAttributes,
200 + ) -> Result<Handle, Self::Error> {
201 + // Only allow writing to /upload/<filename>
202 + let raw_name = Self::extract_filename(&filename).ok_or(StatusCode::PermissionDenied)?;
203 + let safe_name = sanitize_filename(raw_name);
204 +
205 + if safe_name.is_empty() {
206 + return Err(StatusCode::NoSuchFile);
207 + }
208 +
209 + // Check tier — Basic is text-only, no file uploads
210 + if self.is_basic_tier() {
211 + tracing::warn!(user = %self.user_id, "Basic tier user attempted SFTP upload");
212 + return Err(StatusCode::PermissionDenied);
213 + }
214 +
215 + // Check extension
216 + let ext = safe_name.rsplit('.').next().unwrap_or("").to_lowercase();
217 + if !is_allowed_extension(&ext) {
218 + tracing::warn!(user = %self.user_id, ext, "unsupported file extension");
219 + return Err(StatusCode::PermissionDenied);
220 + }
221 +
222 + // Check staging quota before opening
223 + let current_usage = staging::staging_usage(&self.staging_dir).await;
224 + if current_usage >= STAGING_QUOTA_BYTES {
225 + tracing::warn!(user = %self.user_id, usage = current_usage, "staging quota exceeded");
226 + return Err(StatusCode::Failure);
227 + }
228 +
229 + let file_path = self.staging_dir.join(&safe_name);
230 +
231 + if pflags.contains(OpenFlags::WRITE) || pflags.contains(OpenFlags::CREATE) {
232 + // Ensure staging dir exists
233 + if let Err(e) = tokio::fs::create_dir_all(&self.staging_dir).await {
234 + tracing::error!(error = ?e, "failed to create staging dir");
235 + return Err(StatusCode::Failure);
236 + }
237 +
238 + let file = tokio::fs::OpenOptions::new()
239 + .write(true)
240 + .create(true)
241 + .truncate(pflags.contains(OpenFlags::TRUNCATE))
242 + .open(&file_path)
243 + .await
244 + .map_err(|e| {
245 + tracing::error!(error = ?e, "failed to open staging file for write");
246 + StatusCode::Failure
247 + })?;
248 +
249 + let handle = self.alloc_handle();
250 + self.open_files.insert(
251 + handle.clone(),
252 + OpenFile {
253 + path: file_path,
254 + file,
255 + },
256 + );
257 +
258 + tracing::info!(user = %self.user_id, file = %safe_name, "staging file opened for write");
259 + return Ok(Handle { id, handle });
260 + }
261 +
262 + if pflags.contains(OpenFlags::READ) {
263 + let file = tokio::fs::File::open(&file_path).await.map_err(|_| StatusCode::NoSuchFile)?;
264 + let handle = self.alloc_handle();
265 + self.open_files.insert(
266 + handle.clone(),
267 + OpenFile {
268 + path: file_path,
269 + file,
270 + },
271 + );
272 + return Ok(Handle { id, handle });
273 + }
274 +
275 + Err(StatusCode::PermissionDenied)
276 + }
277 +
278 + async fn write(
279 + &mut self,
280 + id: u32,
281 + handle: String,
282 + offset: u64,
283 + data: Vec<u8>,
284 + ) -> Result<Status, Self::Error> {
285 + use tokio::io::{AsyncSeekExt, AsyncWriteExt};
286 +
287 + let of = self
288 + .open_files
289 + .get_mut(&handle)
290 + .ok_or(StatusCode::Failure)?;
291 +
292 + // Check staging quota (approximate — race-free enforcement at close time)
293 + let current_usage = staging::staging_usage(&self.staging_dir).await;
294 + if current_usage + data.len() as u64 > STAGING_QUOTA_BYTES {
295 + return Err(StatusCode::Failure);
296 + }
297 +
298 + of.file
299 + .seek(std::io::SeekFrom::Start(offset))
300 + .await
301 + .map_err(|_| StatusCode::Failure)?;
302 +
303 + of.file
304 + .write_all(&data)
305 + .await
306 + .map_err(|_| StatusCode::Failure)?;
307 +
308 + Ok(self.ok_status(id))
309 + }
310 +
311 + async fn read(
312 + &mut self,
313 + id: u32,
314 + handle: String,
315 + offset: u64,
316 + len: u32,
317 + ) -> Result<Data, Self::Error> {
318 + use tokio::io::{AsyncReadExt, AsyncSeekExt};
319 +
320 + let of = self
321 + .open_files
322 + .get_mut(&handle)
323 + .ok_or(StatusCode::Failure)?;
324 +
325 + of.file
326 + .seek(std::io::SeekFrom::Start(offset))
327 + .await
328 + .map_err(|_| StatusCode::Failure)?;
329 +
330 + let mut buf = vec![0u8; len as usize];
331 + let n = of
332 + .file
333 + .read(&mut buf)
334 + .await
335 + .map_err(|_| StatusCode::Failure)?;
336 +
337 + if n == 0 {
338 + return Err(StatusCode::Eof);
339 + }
340 +
341 + buf.truncate(n);
342 + Ok(Data { id, data: buf })
343 + }
344 +
345 + async fn close(&mut self, id: u32, handle: String) -> Result<Status, Self::Error> {
346 + if self.dir_handles.remove(&handle).is_some() {
347 + return Ok(self.ok_status(id));
348 + }
349 +
350 + if let Some(of) = self.open_files.remove(&handle) {
351 + drop(of.file);
352 + tracing::debug!(user = %self.user_id, path = %of.path.display(), "file handle closed");
353 + return Ok(self.ok_status(id));
354 + }
355 +
356 + Err(StatusCode::Failure)
357 + }
358 +
359 + async fn remove(&mut self, id: u32, filename: String) -> Result<Status, Self::Error> {
360 + let raw_name = Self::extract_filename(&filename).ok_or(StatusCode::NoSuchFile)?;
361 + let safe_name = sanitize_filename(raw_name);
362 + let file_path = self.staging_dir.join(&safe_name);
363 +
364 + tokio::fs::remove_file(&file_path)
365 + .await
366 + .map_err(|_| StatusCode::NoSuchFile)?;
367 +
368 + tracing::info!(user = %self.user_id, file = %safe_name, "staging file removed");
369 + Ok(self.ok_status(id))
370 + }
371 +
372 + async fn mkdir(
373 + &mut self,
374 + _id: u32,
375 + _path: String,
376 + _attrs: FileAttributes,
377 + ) -> Result<Status, Self::Error> {
378 + Err(StatusCode::PermissionDenied)
379 + }
380 +
381 + async fn rmdir(&mut self, _id: u32, _path: String) -> Result<Status, Self::Error> {
382 + Err(StatusCode::PermissionDenied)
383 + }
384 +
385 + async fn rename(
386 + &mut self,
387 + _id: u32,
388 + _oldpath: String,
389 + _newpath: String,
390 + ) -> Result<Status, Self::Error> {
391 + Err(StatusCode::PermissionDenied)
392 + }
393 +
394 + async fn symlink(
395 + &mut self,
396 + _id: u32,
397 + _linkpath: String,
398 + _targetpath: String,
399 + ) -> Result<Status, Self::Error> {
400 + Err(StatusCode::OpUnsupported)
401 + }
402 +
403 + async fn readlink(&mut self, _id: u32, _path: String) -> Result<Name, Self::Error> {
404 + Err(StatusCode::OpUnsupported)
405 + }
406 +
407 + async fn setstat(
408 + &mut self,
409 + id: u32,
410 + _path: String,
411 + _attrs: FileAttributes,
412 + ) -> Result<Status, Self::Error> {
413 + // Silently accept — some SFTP clients send setstat after upload
414 + Ok(self.ok_status(id))
415 + }
416 +
417 + async fn fsetstat(
418 + &mut self,
419 + id: u32,
420 + _handle: String,
421 + _attrs: FileAttributes,
422 + ) -> Result<Status, Self::Error> {
423 + // Silently accept
424 + Ok(self.ok_status(id))
425 + }
426 + }
@@ -0,0 +1,61 @@
1 + //! TerminalHandle: bridges ratatui's Write trait to an SSH channel.
2 + //!
3 + //! Buffers writes in a Vec<u8>, then on flush() sends the buffer
4 + //! contents via an mpsc channel to a spawned task that relays data
5 + //! to the SSH session.
6 +
7 + use std::io;
8 +
9 + use tokio::sync::mpsc;
10 +
11 + /// A `Write` sink that buffers output and flushes it to an SSH channel
12 + /// via an async mpsc sender.
13 + pub struct TerminalHandle {
14 + sink: Vec<u8>,
15 + tx: mpsc::Sender<Vec<u8>>,
16 + }
17 +
18 + impl TerminalHandle {
19 + /// Create a new TerminalHandle and spawn the relay task.
20 + ///
21 + /// The relay task forwards buffered data to the SSH session's
22 + /// channel via `session_handle.data()`.
23 + pub fn new(
24 + session_handle: russh::server::Handle,
25 + channel_id: russh::ChannelId,
26 + ) -> Self {
27 + let (tx, mut rx) = mpsc::channel::<Vec<u8>>(64);
28 +
29 + tokio::spawn(async move {
30 + while let Some(data) = rx.recv().await {
31 + // Handle::data() accepts impl Into<Bytes>; Vec<u8> converts directly.
32 + if session_handle.data(channel_id, data).await.is_err() {
33 + break;
34 + }
35 + }
36 + });
37 +
38 + Self {
39 + sink: Vec::with_capacity(4096),
40 + tx,
41 + }
42 + }
43 + }
44 +
45 + impl io::Write for TerminalHandle {
46 + fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
47 + self.sink.extend_from_slice(buf);
48 + Ok(buf.len())
49 + }
50 +
51 + fn flush(&mut self) -> io::Result<()> {
52 + if self.sink.is_empty() {
53 + return Ok(());
54 + }
55 + let data = std::mem::take(&mut self.sink);
56 + self.tx
57 + .try_send(data)
58 + .map_err(|e| io::Error::new(io::ErrorKind::BrokenPipe, e))?;
59 + Ok(())
60 + }
61 + }
@@ -0,0 +1,283 @@
1 + //! Staging directory management for SFTP uploads.
2 + //!
3 + //! Files uploaded via SFTP land in a per-user staging directory on disk.
4 + //! From there, the TUI publish flow uploads them to S3 via MNW internal API.
5 +
6 + use std::path::{Path, PathBuf};
7 +
8 + use tokio::fs;
9 +
10 + /// A file in the staging directory waiting to be published.
11 + #[derive(Debug, Clone)]
12 + pub struct StagedFile {
13 + pub filename: String,
14 + pub size: u64,
15 + pub modified: std::time::SystemTime,
16 + /// Derived from extension.
17 + pub classification: Option<FileClassification>,
18 + }
19 +
20 + /// Classification of a staged file based on its extension.
21 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
22 + pub struct FileClassification {
23 + pub item_type: &'static str,
24 + pub file_type: &'static str,
25 + pub content_type: &'static str,
26 + }
27 +
28 + /// 1 GB staging quota per user.
29 + pub const STAGING_QUOTA_BYTES: u64 = 1024 * 1024 * 1024;
30 +
31 + /// Get the staging directory path for a user.
32 + pub fn user_staging_dir(base: &Path, user_id: &str) -> PathBuf {
33 + base.join(user_id)
34 + }
35 +
36 + /// List all staged files in a user's staging directory.
37 + pub async fn list_staged_files(dir: &Path) -> Vec<StagedFile> {
38 + let mut files = Vec::new();
39 + let Ok(mut entries) = fs::read_dir(dir).await else {
40 + return files;
41 + };
42 +
43 + while let Ok(Some(entry)) = entries.next_entry().await {
44 + let Ok(metadata) = entry.metadata().await else {
45 + continue;
46 + };
47 + if !metadata.is_file() {
48 + continue;
49 + }
50 +
51 + let filename = entry.file_name().to_string_lossy().to_string();
52 + let ext = filename.rsplit('.').next().unwrap_or("").to_lowercase();
53 + let classification = classify_extension(&ext);
54 +
55 + files.push(StagedFile {
56 + filename,
57 + size: metadata.len(),
58 + modified: metadata.modified().unwrap_or(std::time::SystemTime::UNIX_EPOCH),
59 + classification,
60 + });
61 + }
62 +
63 + files.sort_by(|a, b| b.modified.cmp(&a.modified));
64 + files
65 + }
66 +
67 + /// Calculate total bytes used in a staging directory.
68 + pub async fn staging_usage(dir: &Path) -> u64 {
69 + let mut total = 0u64;
70 + let Ok(mut entries) = fs::read_dir(dir).await else {
71 + return 0;
72 + };
73 +
74 + while let Ok(Some(entry)) = entries.next_entry().await {
75 + if let Ok(metadata) = entry.metadata().await {
76 + if metadata.is_file() {
77 + total += metadata.len();
78 + }
79 + }
80 + }
81 +
82 + total
83 + }
84 +
85 + /// Remove staged files older than `ttl` across all user directories.
86 + pub async fn cleanup_stale(base: &Path, ttl: std::time::Duration) {
87 + let Ok(mut users) = fs::read_dir(base).await else {
88 + return;
89 + };
90 +
91 + let now = std::time::SystemTime::now();
92 + let mut removed = 0u64;
93 +
94 + while let Ok(Some(user_dir)) = users.next_entry().await {
95 + let Ok(metadata) = user_dir.metadata().await else {
96 + continue;
97 + };
98 + if !metadata.is_dir() {
99 + continue;
100 + }
101 +
102 + let Ok(mut files) = fs::read_dir(user_dir.path()).await else {
103 + continue;
104 + };
105 +
106 + let mut dir_empty = true;
107 + while let Ok(Some(file)) = files.next_entry().await {
108 + let Ok(fmeta) = file.metadata().await else {
109 + dir_empty = false;
110 + continue;
111 + };
112 +
113 + let age = fmeta
114 + .modified()
115 + .ok()
116 + .and_then(|m| now.duration_since(m).ok())
117 + .unwrap_or_default();
118 +
119 + if age > ttl {
120 + if fs::remove_file(file.path()).await.is_ok() {
121 + removed += 1;
122 + }
123 + } else {
124 + dir_empty = false;
125 + }
126 + }
127 +
128 + // Remove empty user dirs
129 + if dir_empty {
130 + let _ = fs::remove_dir(user_dir.path()).await;
131 + }
132 + }
133 +
134 + if removed > 0 {
135 + tracing::info!(removed, "cleaned up stale staging files");
136 + }
137 + }
138 +
139 + /// Sanitize a filename: keep only safe characters, prevent path traversal.
140 + pub fn sanitize_filename(name: &str) -> String {
141 + // Take only the final path component
142 + let base = name.rsplit('/').next().unwrap_or(name);
143 + let base = base.rsplit('\\').next().unwrap_or(base);
144 +
145 + let sanitized: String = base
146 + .chars()
147 + .filter(|c| c.is_alphanumeric() || *c == '.' || *c == '-' || *c == '_' || *c == ' ')
148 + .collect();
149 +
150 + let sanitized = sanitized.trim().to_string();
151 +
152 + if sanitized.is_empty() || sanitized == "." || sanitized == ".." {
153 + return "upload".to_string();
154 + }
155 +
156 + // Limit length
157 + if sanitized.len() > 200 {
158 + sanitized[..200].to_string()
159 + } else {
160 + sanitized
161 + }
162 + }
163 +
164 + /// Classify a file extension into item type, file type, and content type.
165 + pub fn classify_extension(ext: &str) -> Option<FileClassification> {
166 + match ext {
167 + // Audio
168 + "mp3" => Some(FileClassification {
169 + item_type: "audio",
170 + file_type: "audio",
171 + content_type: "audio/mpeg",
172 + }),
173 + "wav" => Some(FileClassification {
174 + item_type: "audio",
175 + file_type: "audio",
176 + content_type: "audio/wav",
177 + }),
178 + "flac" => Some(FileClassification {
179 + item_type: "audio",
180 + file_type: "audio",
181 + content_type: "audio/flac",
182 + }),
183 + "ogg" => Some(FileClassification {
184 + item_type: "audio",
185 + file_type: "audio",
186 + content_type: "audio/ogg",
187 + }),
188 + "m4a" => Some(FileClassification {
189 + item_type: "audio",
190 + file_type: "audio",
191 + content_type: "audio/mp4",
192 + }),
193 + "aac" => Some(FileClassification {
194 + item_type: "audio",
195 + file_type: "audio",
196 + content_type: "audio/aac",
197 + }),
198 + // Digital downloads
199 + "zip" => Some(FileClassification {
200 + item_type: "digital",
201 + file_type: "download",
202 + content_type: "application/zip",
203 + }),
204 + "dmg" => Some(FileClassification {
205 + item_type: "digital",
206 + file_type: "download",
207 + content_type: "application/x-apple-diskimage",
208 + }),
209 + "exe" => Some(FileClassification {
210 + item_type: "digital",
211 + file_type: "download",
212 + content_type: "application/vnd.microsoft.portable-executable",
213 + }),
214 + "appimage" => Some(FileClassification {
215 + item_type: "digital",
216 + file_type: "download",
217 + content_type: "application/octet-stream",
218 + }),
219 + "deb" => Some(FileClassification {
220 + item_type: "digital",
221 + file_type: "download",
222 + content_type: "application/vnd.debian.binary-package",
223 + }),
224 + // Plugins
225 + "clap" => Some(FileClassification {
226 + item_type: "plugin",
227 + file_type: "download",
228 + content_type: "application/octet-stream",
229 + }),
230 + "vst3" => Some(FileClassification {
231 + item_type: "plugin",
232 + file_type: "download",
233 + content_type: "application/octet-stream",
234 + }),
235 + _ => None,
236 + }
237 + }
238 +
239 + /// Derive a human-readable title from a filename.
240 + /// Strips extension, replaces `-` and `_` with spaces, title-cases words.
241 + pub fn derive_title(filename: &str) -> String {
242 + let without_ext = match filename.rfind('.') {
243 + Some(pos) if pos > 0 => &filename[..pos],
244 + _ => filename,
245 + };
246 +
247 + without_ext
248 + .replace(['-', '_'], " ")
249 + .split_whitespace()
250 + .map(title_case_word)
251 + .collect::<Vec<_>>()
252 + .join(" ")
253 + }
254 +
255 + fn title_case_word(word: &str) -> String {
256 + let mut chars = word.chars();
257 + match chars.next() {
258 + None => String::new(),
259 + Some(first) => {
260 + let mut s = first.to_uppercase().to_string();
261 + s.extend(chars.map(|c| c.to_ascii_lowercase()));
262 + s
263 + }
264 + }
265 + }
266 +
267 + /// Check if an extension is allowed for upload.
268 + pub fn is_allowed_extension(ext: &str) -> bool {
269 + classify_extension(&ext.to_lowercase()).is_some()
270 + }
271 +
272 + /// Format bytes into a human-readable string.
273 + pub fn format_bytes(bytes: u64) -> String {
274 + if bytes < 1024 {
275 + format!("{} B", bytes)
276 + } else if bytes < 1024 * 1024 {
277 + format!("{:.1} KB", bytes as f64 / 1024.0)
278 + } else if bytes < 1024 * 1024 * 1024 {
279 + format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
280 + } else {
281 + format!("{:.2} GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0))
282 + }
283 + }
@@ -0,0 +1,343 @@
1 + //! Analytics dashboard — revenue chart, stats, top projects, transactions, export.
2 +
3 + use ratatui::Frame;
4 + use ratatui::layout::{Constraint, Layout};
5 + use ratatui::style::{Color, Modifier, Style};
6 + use ratatui::text::{Line, Span};
7 + use ratatui::widgets::{Bar, BarChart, BarGroup, Block, Borders, Paragraph, Row, Table};
8 +
9 + use super::App;
10 +
11 + pub fn render(frame: &mut Frame, app: &App) {
12 + let area = frame.area();
13 +
14 + let range_label = match app.analytics_range.as_str() {
15 + "7d" => "7 days",
16 + "30d" => "30 days",
17 + "90d" => "90 days",
18 + "all" => "All time",
19 + _ => &app.analytics_range,
20 + };
21 +
22 + let title = Line::from(vec![
23 + Span::styled(" Makenot.work ", Style::default().add_modifier(Modifier::BOLD)),
24 + Span::raw(" -- "),
25 + Span::styled("Analytics", Style::default().add_modifier(Modifier::BOLD)),
26 + Span::raw(format!(" ({}) ", range_label)),
27 + ]);
28 +
29 + let block = Block::default()
30 + .title(title)
31 + .borders(Borders::ALL)
32 + .border_style(Style::default().fg(Color::Gray));
33 +
34 + let inner = block.inner(area);
35 + frame.render_widget(block, area);
36 +
37 + let chunks = Layout::vertical([
38 + Constraint::Length(1), // spacer
39 + Constraint::Length(3), // stat cards
40 + Constraint::Length(1), // spacer
41 + Constraint::Min(6), // chart or transactions
42 + Constraint::Length(1), // status
43 + Constraint::Length(1), // keybindings
44 + ])
45 + .split(inner);
46 +
47 + // Stat cards
48 + render_stat_cards(frame, app, chunks[1]);
49 +
50 + // Main content area
51 + if app.loading {
52 + let loading = Paragraph::new(" Loading...");
53 + frame.render_widget(loading, chunks[3]);
54 + } else if app.analytics_show_transactions {
55 + render_transactions(frame, app, chunks[3]);
56 + } else {
57 + render_chart_and_projects(frame, app, chunks[3]);
58 + }
59 +
60 + // Status line
61 + if let Some(ref status) = app.analytics_status {
62 + let style = if status.starts_with("Error") {
63 + Style::default().fg(Color::Red)
64 + } else {
65 + Style::default().fg(Color::Green)
66 + };
67 + let status_line = Paragraph::new(Line::from(vec![
68 + Span::raw(" "),
69 + Span::styled(status.as_str(), style),
70 + ]));
71 + frame.render_widget(status_line, chunks[4]);
72 + }
73 +
74 + // Keybindings
75 + let key_spans = vec![
76 + Span::raw(" "),
77 + Span::styled("[1-4]", Style::default().add_modifier(Modifier::BOLD)),
78 + Span::raw(" Range "),
79 + Span::styled("[t]", Style::default().add_modifier(Modifier::BOLD)),
80 + Span::raw(" Transactions "),
81 + Span::styled("[e]", Style::default().add_modifier(Modifier::BOLD)),
82 + Span::raw(" Export CSV "),
83 + Span::styled("[r]", Style::default().add_modifier(Modifier::BOLD)),
84 + Span::raw(" Refresh "),
85 + Span::styled("[Esc]", Style::default().add_modifier(Modifier::BOLD)),
86 + Span::raw(" Back"),
87 + ];
88 +
89 + let keys = Paragraph::new(Line::from(key_spans)).style(Style::default().fg(Color::DarkGray));
90 + frame.render_widget(keys, chunks[5]);
91 + }
92 +
93 + fn render_stat_cards(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
94 + let stat_chunks = Layout::horizontal([
95 + Constraint::Ratio(1, 3),
96 + Constraint::Ratio(1, 3),
97 + Constraint::Ratio(1, 3),
98 + ])
99 + .split(area);
100 +
101 + let data = &app.analytics_data;
102 +
103 + let stats = [
104 + (
105 + "Revenue",
106 + format_cents(data.as_ref().map(|d| d.current_revenue_cents).unwrap_or(0)),
107 + data.as_ref().map(|d| pct_change(d.current_revenue_cents, d.previous_revenue_cents)),
108 + ),
109 + (
110 + "Sales",
111 + data.as_ref().map(|d| d.current_sales.to_string()).unwrap_or_else(|| "--".into()),
112 + data.as_ref().map(|d| pct_change(d.current_sales, d.previous_sales)),
113 + ),
114 + (
115 + "Followers",
116 + data.as_ref().map(|d| d.current_followers.to_string()).unwrap_or_else(|| "--".into()),
117 + data.as_ref().map(|d| pct_change(d.current_followers, d.previous_followers)),
118 + ),
119 + ];
120 +
121 + for (i, (label, value, change)) in stats.iter().enumerate() {
122 + let block = Block::default()
123 + .borders(Borders::ALL)
124 + .border_style(Style::default().fg(Color::DarkGray));
125 + let inner = block.inner(stat_chunks[i]);
126 + frame.render_widget(block, stat_chunks[i]);
127 +
128 + let mut spans = vec![
129 + Span::styled(
130 + format!(" {value}"),
131 + Style::default().add_modifier(Modifier::BOLD),
132 + ),
133 + Span::styled(format!(" {label}"), Style::default().fg(Color::DarkGray)),
134 + ];
135 +
136 + if let Some(Some((text, positive))) = change {
137 + let color = if *positive { Color::Green } else { Color::Red };
138 + spans.push(Span::styled(format!(" {text}"), Style::default().fg(color)));
139 + }
140 +
141 + let text = Paragraph::new(Line::from(spans));
142 + frame.render_widget(text, inner);
143 + }
144 + }
145 +
146 + fn render_chart_and_projects(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
147 + let chunks = Layout::vertical([
148 + Constraint::Length(1), // chart header
149 + Constraint::Min(4), // chart
150 + Constraint::Length(1), // spacer
151 + Constraint::Length(1), // projects header
152 + Constraint::Min(2), // projects table
153 + ])
154 + .split(area);
155 +
156 + // Chart header
157 + let header = Paragraph::new(Line::from(vec![
158 + Span::raw(" "),
159 + Span::styled("Revenue", Style::default().add_modifier(Modifier::BOLD)),
160 + ]));
161 + frame.render_widget(header, chunks[0]);
162 +
163 + // Revenue bar chart
164 + if let Some(ref data) = app.analytics_data {
165 + if data.buckets.is_empty() {
166 + let empty = Paragraph::new(" No revenue data for this period.");
167 + frame.render_widget(empty, chunks[1]);
168 + } else {
169 + let max_val = data.buckets.iter().map(|b| b.revenue_cents).max().unwrap_or(1).max(1);
170 +
171 + let bars: Vec<Bar> = data
172 + .buckets
173 + .iter()
174 + .map(|b| {
175 + Bar::default()
176 + .value(b.revenue_cents as u64)
177 + .label(Line::from(b.label.clone()))
178 + .text_value(if b.revenue_cents > 0 {
179 + format_cents(b.revenue_cents)
180 + } else {
181 + String::new()
182 + })
183 + .style(Style::default().fg(Color::Cyan))
184 + })
185 + .collect();
186 +
187 + let chart = BarChart::default()
188 + .data(BarGroup::default().bars(&bars))
189 + .bar_width(
190 + (chunks[1].width as usize)
191 + .checked_div(bars.len().max(1))
192 + .unwrap_or(3)
193 + .min(8)
194 + .max(1) as u16,
195 + )
196 + .bar_gap(1)
197 + .max(max_val as u64);
198 +
199 + frame.render_widget(chart, chunks[1]);
200 + }
201 + } else {
202 + let empty = Paragraph::new(" No data.");
203 + frame.render_widget(empty, chunks[1]);
204 + }
205 +
206 + // Projects header
207 + let proj_header = Paragraph::new(Line::from(vec![
208 + Span::raw(" "),
209 + Span::styled("Top Projects", Style::default().add_modifier(Modifier::BOLD)),
210 + ]));
211 + frame.render_widget(proj_header, chunks[3]);
212 +
213 + // Top projects
214 + if let Some(ref data) = app.analytics_data {
215 + if data.top_projects.is_empty() {
216 + let empty = Paragraph::new(" No project revenue yet.");
217 + frame.render_widget(empty, chunks[4]);
218 + } else {
219 + let rows: Vec<Row> = data
220 + .top_projects
221 + .iter()
222 + .map(|p| {
223 + Row::new(vec![
224 + format!(" {}", p.title),
225 + format_cents(p.revenue_cents),
226 + ])
227 + })
228 + .collect();
229 +
230 + let header = Row::new(vec![" Project", "Revenue"])
231 + .style(
232 + Style::default()
233 + .fg(Color::DarkGray)
234 + .add_modifier(Modifier::BOLD),
235 + )
236 + .bottom_margin(0);
237 +
238 + let widths = [Constraint::Min(20), Constraint::Length(12)];
239 + let table = Table::new(rows, widths).header(header);
240 + frame.render_widget(table, chunks[4]);
241 + }
242 + }
243 + }
244 +
245 + fn render_transactions(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
246 + let chunks = Layout::vertical([
247 + Constraint::Length(1), // header
248 + Constraint::Min(3), // table
249 + ])
250 + .split(area);
251 +
252 + let count = app.transactions.len();
253 + let header = Paragraph::new(Line::from(vec![
254 + Span::raw(" "),
255 + Span::styled("Transactions", Style::default().add_modifier(Modifier::BOLD)),
256 + if count == 0 {
257 + Span::raw("")
258 + } else {
259 + Span::raw(format!(" ({})", count))
260 + },
261 + ]));
262 + frame.render_widget(header, chunks[0]);
263 +
264 + if app.transactions.is_empty() {
265 + let empty = Paragraph::new(" No transactions.");
266 + frame.render_widget(empty, chunks[1]);
267 + } else {
268 + let selected = app.selected_index;
269 + let rows: Vec<Row> = app
270 + .transactions
271 + .iter()
272 + .enumerate()
273 + .map(|(i, tx)| {
274 + let style = if i == selected {
275 + Style::default()
276 + .bg(Color::DarkGray)
277 + .fg(Color::White)
278 + .add_modifier(Modifier::BOLD)
279 + } else {
280 + Style::default()
281 + };
282 +
283 + let title = tx.item_title.as_deref().unwrap_or("--");
284 + let amount = format_cents(tx.amount_cents as i64);
285 + let date = tx.created_at.get(..10).unwrap_or(&tx.created_at);
286 +
287 + Row::new(vec![
288 + format!(" {}", title),
289 + amount,
290 + tx.status.clone(),
291 + date.to_string(),
292 + ])
293 + .style(style)
294 + })
295 + .collect();
296 +
297 + let table_header = Row::new(vec![" Item", "Amount", "Status", "Date"])
298 + .style(
299 + Style::default()
300 + .fg(Color::DarkGray)
301 + .add_modifier(Modifier::BOLD),
302 + )
303 + .bottom_margin(0);
304 +
305 + let widths = [
306 + Constraint::Min(20),
307 + Constraint::Length(12),
308 + Constraint::Length(10),
309 + Constraint::Length(12),
310 + ];
311 +
312 + let table = Table::new(rows, widths).header(table_header);
313 + frame.render_widget(table, chunks[1]);
314 + }
315 + }
316 +
317 + fn format_cents(cents: i64) -> String {
318 + if cents == 0 {
319 + "$0".to_string()
320 + } else {
321 + format!("${}.{:02}", cents / 100, cents.abs() % 100)
322 + }
323 + }
324 +
325 + fn pct_change(current: i64, previous: i64) -> Option<(String, bool)> {
326 + if previous == 0 {
327 + if current > 0 {
328 + return Some(("+inf%".to_string(), true));
329 + }
330 + return None;
331 + }
332 + let change = ((current - previous) as f64 / previous as f64 * 100.0).round() as i64;
333 + if change == 0 {
334 + None
335 + } else {
336 + let text = if change > 0 {
337 + format!("+{}%", change)
338 + } else {
339 + format!("{}%", change)
340 + };
341 + Some((text, change > 0))
342 + }
343 + }
@@ -0,0 +1,158 @@
1 + //! Blog post management screen — list, create, delete posts.
2 +
3 + use ratatui::Frame;
4 + use ratatui::layout::{Constraint, Layout};
5 + use ratatui::style::{Color, Modifier, Style};
6 + use ratatui::text::{Line, Span};
7 + use ratatui::widgets::{Block, Borders, Paragraph, Row, Table};
8 +
9 + use super::App;
10 +
11 + pub fn render(frame: &mut Frame, app: &App) {
12 + let area = frame.area();
13 +
14 + let project_title = app
15 + .blog_project_title
16 + .as_deref()
17 + .unwrap_or("Blog");
18 +
19 + let title = Line::from(vec![
20 + Span::styled(" Makenot.work ", Style::default().add_modifier(Modifier::BOLD)),
21 + Span::raw(" -- "),
22 + Span::styled(project_title, Style::default().add_modifier(Modifier::BOLD)),
23 + Span::raw(" -- Blog "),
24 + ]);
25 +
26 + let block = Block::default()
27 + .title(title)
28 + .borders(Borders::ALL)
29 + .border_style(Style::default().fg(Color::Gray));
30 +
31 + let inner = block.inner(area);
32 + frame.render_widget(block, area);
33 +
34 + let chunks = Layout::vertical([
35 + Constraint::Length(1), // spacer
36 + Constraint::Length(1), // section header
37 + Constraint::Min(3), // post list
38 + Constraint::Length(1), // status line
39 + Constraint::Length(1), // keybindings
40 + ])
41 + .split(inner);
42 +
43 + // Section header
44 + let count = app.blog_posts.len();
45 + let header = Paragraph::new(Line::from(vec![
46 + Span::raw(" "),
47 + Span::styled("Posts", Style::default().add_modifier(Modifier::BOLD)),
48 + if count == 0 {
49 + Span::raw("")
50 + } else {
51 + Span::raw(format!(" ({})", count))
52 + },
53 + ]));
54 + frame.render_widget(header, chunks[1]);
55 +
56 + // Post list
57 + if app.loading {
58 + let loading = Paragraph::new(" Loading...");
59 + frame.render_widget(loading, chunks[2]);
60 + } else if app.blog_posts.is_empty() {
61 + let empty = Paragraph::new(" No blog posts. Press [n] to create one.");
62 + frame.render_widget(empty, chunks[2]);
63 + } else {
64 + render_post_table(frame, app, chunks[2]);
65 + }
66 +
67 + // Status line
68 + if let Some(ref status) = app.blog_status {
69 + let style = if status.starts_with("Error") {
70 + Style::default().fg(Color::Red)
71 + } else {
72 + Style::default().fg(Color::Green)
73 + };
74 + let status_line = Paragraph::new(Line::from(vec![
75 + Span::raw(" "),
76 + Span::styled(status.as_str(), style),
77 + ]));
78 + frame.render_widget(status_line, chunks[3]);
79 + }
80 +
81 + // Keybindings
82 + let mut key_spans = vec![
83 + Span::raw(" "),
84 + Span::styled("[j/k]", Style::default().add_modifier(Modifier::BOLD)),
85 + Span::raw(" Nav "),
86 + Span::styled("[n]", Style::default().add_modifier(Modifier::BOLD)),
87 + Span::raw(" New "),
88 + ];
89 +
90 + if !app.blog_posts.is_empty() {
91 + key_spans.extend([
92 + Span::styled("[d]", Style::default().add_modifier(Modifier::BOLD)),
93 + Span::raw(" Delete "),
94 + ]);
95 + }
96 +
97 + key_spans.extend([
98 + Span::styled("[r]", Style::default().add_modifier(Modifier::BOLD)),
99 + Span::raw(" Refresh "),
100 + Span::styled("[Esc]", Style::default().add_modifier(Modifier::BOLD)),
101 + Span::raw(" Back"),
102 + ]);
103 +
104 + let keys = Paragraph::new(Line::from(key_spans)).style(Style::default().fg(Color::DarkGray));
105 + frame.render_widget(keys, chunks[4]);
106 + }
107 +
108 + fn render_post_table(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
109 + let selected = app.selected_index;
110 + let rows: Vec<Row> = app
111 + .blog_posts
112 + .iter()
113 + .enumerate()
114 + .map(|(i, post)| {
115 + let style = if i == selected {
116 + Style::default()
117 + .bg(Color::DarkGray)
118 + .fg(Color::White)
119 + .add_modifier(Modifier::BOLD)
120 + } else {
121 + Style::default()
122 + };
123 +
124 + let status = if post.is_published {
125 + "published"
126 + } else {
127 + "draft"
128 + };
129 + let date = post.created_at.get(..10).unwrap_or(&post.created_at);
130 +
131 + Row::new(vec![
132 + format!(" {}", post.title),
133 + post.slug.clone(),
134 + status.to_string(),
135 + date.to_string(),
136 + ])
137 + .style(style)
138 + })
139 + .collect();
140 +
141 + let header = Row::new(vec![" Title", "Slug", "Status", "Created"])
142 + .style(
143 + Style::default()
144 + .fg(Color::DarkGray)
145 + .add_modifier(Modifier::BOLD),
146 + )
147 + .bottom_margin(0);
148 +
149 + let widths = [
150 + Constraint::Min(20),
151 + Constraint::Length(20),
152 + Constraint::Length(10),
153 + Constraint::Length(12),
154 + ];
155 +
156 + let table = Table::new(rows, widths).header(header);
157 + frame.render_widget(table, area);
158 + }
@@ -0,0 +1,226 @@
1 + //! Home screen — project list with stats overview.
2 +
3 + use ratatui::Frame;
4 + use ratatui::layout::{Constraint, Layout};
5 + use ratatui::style::{Color, Modifier, Style};
6 + use ratatui::text::{Line, Span};
7 + use ratatui::widgets::{Block, Borders, Paragraph, Row, Table};
8 +
9 + use super::App;
10 +
11 + pub fn render(frame: &mut Frame, app: &App) {
12 + let area = frame.area();
13 +
14 + let tier_label = app
15 + .user
16 + .creator_tier
17 + .as_deref()
18 + .map(format_tier)
19 + .unwrap_or("No tier");
20 +
21 + let title = Line::from(vec![
22 + Span::styled(" Makenot.work ", Style::default().add_modifier(Modifier::BOLD)),
23 + Span::raw(" ── "),
24 + Span::styled(
25 + &app.user.username,
26 + Style::default().add_modifier(Modifier::BOLD),
27 + ),
28 + Span::raw(" ── "),
29 + Span::raw(tier_label),
30 + Span::raw(" "),
31 + ]);
32 +
33 + let block = Block::default()
34 + .title(title)
35 + .borders(Borders::ALL)
36 + .border_style(Style::default().fg(Color::Gray));
37 +
38 + let inner = block.inner(area);
39 + frame.render_widget(block, area);
40 +
41 + let chunks = Layout::vertical([
42 + Constraint::Length(1), // spacer
43 + Constraint::Length(3), // stats bar
44 + Constraint::Length(1), // spacer
45 + Constraint::Length(1), // section header
46 + Constraint::Min(3), // project list
47 + Constraint::Length(1), // keybindings
48 + ])
49 + .split(inner);
50 +
51 + // Stats bar
52 + render_stats(frame, app, chunks[1]);
53 +
54 + // Section header
55 + let header = Paragraph::new(Line::from(vec![
56 + Span::raw(" "),
57 + Span::styled("Projects", Style::default().add_modifier(Modifier::BOLD)),
58 + if app.projects.is_empty() {
59 + Span::raw("")
60 + } else {
61 + Span::raw(format!(" ({})", app.projects.len()))
62 + },
63 + ]));
64 + frame.render_widget(header, chunks[3]);
65 +
66 + // Project list
67 + if app.loading {
68 + let loading = Paragraph::new(" Loading...");
69 + frame.render_widget(loading, chunks[4]);
70 + } else if app.projects.is_empty() {
71 + let empty = Paragraph::new(" No projects yet.");
72 + frame.render_widget(empty, chunks[4]);
73 + } else {
74 + render_project_table(frame, app, chunks[4]);
75 + }
76 +
77 + // Keybindings
78 + let keys = Paragraph::new(Line::from(vec![
79 + Span::raw(" "),
80 + Span::styled("[j/k]", Style::default().add_modifier(Modifier::BOLD)),
81 + Span::raw(" Navigate "),
82 + Span::styled("[Enter]", Style::default().add_modifier(Modifier::BOLD)),
83 + Span::raw(" Open "),
84 + Span::styled("[u]", Style::default().add_modifier(Modifier::BOLD)),
85 + Span::raw(" Upload "),
86 + Span::styled("[a]", Style::default().add_modifier(Modifier::BOLD)),
87 + Span::raw(" Analytics "),
88 + Span::styled("[c]", Style::default().add_modifier(Modifier::BOLD)),
89 + Span::raw(" Promo "),
90 + Span::styled("[s]", Style::default().add_modifier(Modifier::BOLD)),
91 + Span::raw(" Settings "),
92 + Span::styled("[r]", Style::default().add_modifier(Modifier::BOLD)),
93 + Span::raw(" Refresh "),
94 + Span::styled("[q]", Style::default().add_modifier(Modifier::BOLD)),
95 + Span::raw(" Quit"),
96 + ]))
97 + .style(Style::default().fg(Color::DarkGray));
98 + frame.render_widget(keys, chunks[5]);
99 + }
100 +
101 + fn render_stats(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
102 + let stats_chunks = Layout::horizontal([
103 + Constraint::Ratio(1, 4),
104 + Constraint::Ratio(1, 4),
105 + Constraint::Ratio(1, 4),
106 + Constraint::Ratio(1, 4),
107 + ])
108 + .split(area);
109 +
110 + let (revenue, sales, followers, items) = if let Some(ref s) = app.stats {
111 + (
112 + format_cents(s.current_revenue_cents),
113 + s.current_sales.to_string(),
114 + s.current_followers.to_string(),
115 + s.total_items.to_string(),
116 + )
117 + } else {
118 + ("--".into(), "--".into(), "--".into(), "--".into())
119 + };
120 +
121 + let stat_items = [
122 + ("Revenue", &revenue),
123 + ("Sales", &sales),
124 + ("Followers", &followers),
125 + ("Items", &items),
126 + ];
127 +
128 + for (i, (label, value)) in stat_items.iter().enumerate() {
129 + let block = Block::default()
130 + .borders(Borders::ALL)
131 + .border_style(Style::default().fg(Color::DarkGray));
132 + let inner = block.inner(stats_chunks[i]);
133 + frame.render_widget(block, stats_chunks[i]);
134 +
135 + let text = Paragraph::new(Line::from(vec![
136 + Span::styled(
137 + format!(" {value}"),
138 + Style::default().add_modifier(Modifier::BOLD),
139 + ),
140 + Span::styled(format!(" {label}"), Style::default().fg(Color::DarkGray)),
141 + ]));
142 + frame.render_widget(text, inner);
143 + }
144 + }
145 +
146 + fn render_project_table(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
147 + let selected = app.selected_index;
148 + let rows: Vec<Row> = app
149 + .projects
150 + .iter()
151 + .enumerate()
152 + .map(|(i, p)| {
153 + let style = if i == selected {
154 + Style::default()
155 + .bg(Color::DarkGray)
156 + .fg(Color::White)
157 + .add_modifier(Modifier::BOLD)
158 + } else {
159 + Style::default()
160 + };
161 +
162 + let visibility = if p.is_public { "public" } else { "draft" };
163 +
164 + Row::new(vec![
165 + format!(" {}", p.title),
166 + format_project_type(&p.project_type),
167 + visibility.to_string(),
168 + p.item_count.to_string(),
169 + format_cents(p.revenue_cents),
170 + ])
171 + .style(style)
172 + })
173 + .collect();
174 +
175 + let header = Row::new(vec![" Title", "Type", "Status", "Items", "Revenue"])
176 + .style(
177 + Style::default()
178 + .fg(Color::DarkGray)
179 + .add_modifier(Modifier::BOLD),
180 + )
181 + .bottom_margin(0);
182 +
183 + let widths = [
184 + Constraint::Min(20),
185 + Constraint::Length(12),
186 + Constraint::Length(8),
187 + Constraint::Length(7),
188 + Constraint::Length(12),
189 + ];
190 +
191 + let table = Table::new(rows, widths).header(header);
192 + frame.render_widget(table, area);
193 + }
194 +
195 + fn format_cents(cents: i64) -> String {
196 + if cents == 0 {
197 + "$0".to_string()
198 + } else {
199 + format!("${}.{:02}", cents / 100, cents % 100)
200 + }
201 + }
202 +
203 + fn format_tier(tier: &str) -> &str {
204 + match tier {
205 + "basic" => "Basic",
206 + "small_files" => "Small Files",
207 + "big_files" => "Big Files",
208 + "streaming" => "Streaming",
209 + _ => tier,
210 + }
211 + }
212 +
213 + fn format_project_type(pt: &str) -> String {
214 + match pt {
215 + "software" => "Software",
216 + "music" => "Music",
217 + "blog" => "Blog",
218 + "video" => "Video",
219 + "audio" => "Audio",
220 + "art" => "Art",
221 + "writing" => "Writing",
222 + "education" => "Education",
223 + other => other,
224 + }
225 + .to_string()
226 + }
@@ -0,0 +1,306 @@
1 + //! Item detail screen — view/edit item fields, versions, publish status.
2 +
3 + use ratatui::Frame;
4 + use ratatui::layout::{Constraint, Layout};
5 + use ratatui::style::{Color, Modifier, Style};
6 + use ratatui::text::{Line, Span};
7 + use ratatui::widgets::{Block, Borders, Paragraph, Row, Table};
8 +
9 + use crate::api::ItemDetail;
10 + use crate::staging;
11 +
12 + use super::App;
13 +
14 + pub fn render(frame: &mut Frame, app: &App) {
15 + let area = frame.area();
16 +
17 + let item = match &app.item_detail {
18 + Some(d) => d,
19 + None => {
20 + let loading = Paragraph::new(" Loading...");
21 + frame.render_widget(loading, area);
22 + return;
23 + }
24 + };
25 +
26 + let status_label = if item.is_public { "Published" } else { "Draft" };
27 +
28 + let title = Line::from(vec![
29 + Span::styled(" Makenot.work ", Style::default().add_modifier(Modifier::BOLD)),
30 + Span::raw(" -- "),
31 + Span::styled(&item.title, Style::default().add_modifier(Modifier::BOLD)),
32 + Span::raw(format!(" -- {} -- {} ", format_item_type(&item.item_type), status_label)),
33 + ]);
34 +
35 + let block = Block::default()
36 + .title(title)
37 + .borders(Borders::ALL)
38 + .border_style(Style::default().fg(Color::Gray));
39 +
40 + let inner = block.inner(area);
41 + frame.render_widget(block, area);
42 +
43 + let chunks = Layout::vertical([
44 + Constraint::Length(1), // spacer
45 + Constraint::Length(6), // item info
46 + Constraint::Length(1), // spacer
47 + Constraint::Length(1), // versions header
48 + Constraint::Min(3), // versions list
49 + Constraint::Length(1), // status line
50 + Constraint::Length(1), // keybindings
51 + ])
52 + .split(inner);
53 +
54 + // Item info
55 + render_item_info(frame, app, item, chunks[1]);
56 +
57 + // Versions header
58 + let version_count = app.item_versions.len();
59 + let version_header = Paragraph::new(Line::from(vec![
60 + Span::raw(" "),
61 + Span::styled("Versions", Style::default().add_modifier(Modifier::BOLD)),
62 + if version_count == 0 {
63 + Span::raw("")
64 + } else {
65 + Span::raw(format!(" ({})", version_count))
66 + },
67 + ]));
68 + frame.render_widget(version_header, chunks[3]);
69 +
70 + // Versions list
71 + if app.loading {
72 + let loading = Paragraph::new(" Loading...");
73 + frame.render_widget(loading, chunks[4]);
74 + } else if app.item_versions.is_empty() {
75 + let empty = Paragraph::new(" No versions.");
76 + frame.render_widget(empty, chunks[4]);
77 + } else {
78 + render_versions_table(frame, app, chunks[4]);
79 + }
80 +
81 + // Status line
82 + if let Some(ref status) = app.item_status {
83 + let style = if status.starts_with("Error") {
84 + Style::default().fg(Color::Red)
85 + } else {
86 + Style::default().fg(Color::Green)
87 + };
88 + let status_line = Paragraph::new(Line::from(vec![
89 + Span::raw(" "),
90 + Span::styled(status.as_str(), style),
91 + ]));
92 + frame.render_widget(status_line, chunks[5]);
93 + }
94 +
95 + // Keybindings
96 + let mut key_spans = vec![Span::raw(" ")];
97 +
98 + if app.item_editing.is_some() {
99 + key_spans.extend([
100 + Span::styled("[Enter]", Style::default().add_modifier(Modifier::BOLD)),
101 + Span::raw(" Confirm "),
102 + Span::styled("[Esc]", Style::default().add_modifier(Modifier::BOLD)),
103 + Span::raw(" Cancel"),
104 + ]);
105 + } else {
106 + key_spans.extend([
107 + Span::styled("[e]", Style::default().add_modifier(Modifier::BOLD)),
108 + Span::raw(" Edit "),
109 + ]);
110 + if item.is_public {
111 + key_spans.extend([
112 + Span::styled("[u]", Style::default().add_modifier(Modifier::BOLD)),
113 + Span::raw(" Unpublish "),
114 + ]);
115 + } else {
116 + key_spans.extend([
117 + Span::styled("[p]", Style::default().add_modifier(Modifier::BOLD)),
118 + Span::raw(" Publish "),
119 + ]);
120 + }
121 + key_spans.extend([
122 + Span::styled("[d]", Style::default().add_modifier(Modifier::BOLD)),
123 + Span::raw(" Delete "),
124 + Span::styled("[r]", Style::default().add_modifier(Modifier::BOLD)),
125 + Span::raw(" Refresh "),
126 + Span::styled("[Esc]", Style::default().add_modifier(Modifier::BOLD)),
127 + Span::raw(" Back"),
128 + ]);
129 + }
130 +
131 + let keys = Paragraph::new(Line::from(key_spans)).style(Style::default().fg(Color::DarkGray));
132 + frame.render_widget(keys, chunks[6]);
133 + }
134 +
135 + fn render_item_info(
136 + frame: &mut Frame,
137 + app: &App,
138 + item: &ItemDetail,
139 + area: ratatui::layout::Rect,
140 + ) {
141 + let price = format_price(item.price_cents);
142 + let desc_preview = item
143 + .description
144 + .as_deref()
145 + .unwrap_or("(no description)")
146 + .chars()
147 + .take(60)
148 + .collect::<String>();
149 +
150 + // Show edit indicator for editing field
151 + let editing = app.item_editing;
152 + let edit_marker = |field: ItemEditField| -> &str {
153 + if editing == Some(field) {
154 + "> "
155 + } else {
156 + " "
157 + }
158 + };
159 +
160 + let lines = vec![
161 + Line::from(vec![
162 + Span::raw(edit_marker(ItemEditField::Title)),
163 + Span::styled("Title: ", Style::default().fg(Color::DarkGray)),
164 + if editing == Some(ItemEditField::Title) {
165 + Span::styled(
166 + format!("{}_", app.edit_buffer),
167 + Style::default().add_modifier(Modifier::UNDERLINED),
168 + )
169 + } else {
170 + Span::raw(item.title.clone())
171 + },
172 + ]),
173 + Line::from(vec![
174 + Span::raw(edit_marker(ItemEditField::Description)),
175 + Span::styled("Description: ", Style::default().fg(Color::DarkGray)),
176 + if editing == Some(ItemEditField::Description) {
177 + Span::styled(
178 + format!("{}_", app.edit_buffer),
179 + Style::default().add_modifier(Modifier::UNDERLINED),
180 + )
181 + } else {
182 + Span::raw(desc_preview)
183 + },
184 + ]),
185 + Line::from(vec![
186 + Span::raw(edit_marker(ItemEditField::Price)),
187 + Span::styled("Price: ", Style::default().fg(Color::DarkGray)),
188 + if editing == Some(ItemEditField::Price) {
189 + Span::styled(
190 + format!("${}_", app.edit_buffer),
191 + Style::default().add_modifier(Modifier::UNDERLINED),
192 + )
193 + } else {
194 + Span::raw(price)
195 + },
196 + ]),
197 + Line::from(vec![
198 + Span::raw(" "),
199 + Span::styled("Type: ", Style::default().fg(Color::DarkGray)),
200 + Span::raw(format_item_type(&item.item_type)),
201 + Span::raw(" "),
202 + Span::styled("Slug: ", Style::default().fg(Color::DarkGray)),
203 + Span::raw(item.slug.clone()),
204 + ]),
205 + Line::from(vec![
206 + Span::raw(" "),
207 + Span::styled("Sales: ", Style::default().fg(Color::DarkGray)),
208 + Span::raw(item.sales_count.to_string()),
209 + Span::raw(" "),
210 + Span::styled("Downloads: ", Style::default().fg(Color::DarkGray)),
211 + Span::raw(item.download_count.to_string()),
212 + if item.play_count > 0 {
213 + Span::raw(format!(" Plays: {}", item.play_count))
214 + } else {
215 + Span::raw("")
216 + },
217 + ]),
218 + Line::from(vec![
219 + Span::raw(" "),
220 + Span::styled("Audio: ", Style::default().fg(Color::DarkGray)),
221 + Span::raw(if item.has_audio { "yes" } else { "no" }),
222 + Span::raw(" "),
223 + Span::styled("Cover: ", Style::default().fg(Color::DarkGray)),
224 + Span::raw(if item.has_cover { "yes" } else { "no" }),
225 + ]),
226 + ];
227 +
228 + let info = Paragraph::new(lines);
229 + frame.render_widget(info, area);
230 + }
231 +
232 + fn render_versions_table(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
233 + let rows: Vec<Row> = app
234 + .item_versions
235 + .iter()
236 + .map(|v| {
237 + let current = if v.is_current { "*" } else { " " };
238 + let size = v
239 + .file_size_bytes
240 + .map(|b| staging::format_bytes(b as u64))
241 + .unwrap_or_else(|| "--".to_string());
242 + let name = v.file_name.as_deref().unwrap_or("--");
243 + let date = v.created_at.get(..10).unwrap_or(&v.created_at);
244 +
245 + Row::new(vec![
246 + format!(" {}{}", current, v.version_number),
247 + name.to_string(),
248 + size,
249 + v.download_count.to_string(),
250 + date.to_string(),
251 + ])
252 + })
253 + .collect();
254 +
255 + let header = Row::new(vec![" Version", "File", "Size", "Downloads", "Date"])
256 + .style(
257 + Style::default()
258 + .fg(Color::DarkGray)
259 + .add_modifier(Modifier::BOLD),
260 + )
261 + .bottom_margin(0);
262 +
263 + let widths = [
264 + Constraint::Length(12),
265 + Constraint::Min(16),
266 + Constraint::Length(10),
267 + Constraint::Length(10),
268 + Constraint::Length(12),
269 + ];
270 +
271 + let table = Table::new(rows, widths).header(header);
272 + frame.render_widget(table, area);
273 + }
274 +
275 + /// Which field is being edited on the item detail screen.
276 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
277 + pub enum ItemEditField {
278 + Title,
279 + Description,
280 + Price,
281 + }
282 +
283 + fn format_price(cents: i32) -> String {
284 + if cents == 0 {
285 + "Free".to_string()
286 + } else {
287 + format!("${}.{:02}", cents / 100, cents % 100)
288 + }
289 + }
290 +
291 + fn format_item_type(it: &str) -> &str {
292 + match it {
293 + "audio" => "Audio",
294 + "text" => "Text",
295 + "video" => "Video",
296 + "image" => "Image",
297 + "plugin" => "Plugin",
298 + "preset" => "Preset",
299 + "sample" => "Sample",
300 + "course" => "Course",
301 + "template" => "Template",
302 + "digital" => "Digital",
303 + "bundle" => "Bundle",
304 + other => other,
305 + }
306 + }
@@ -0,0 +1,162 @@
1 + //! License key management screen — list, generate, revoke keys.
2 +
3 + use ratatui::Frame;
4 + use ratatui::layout::{Constraint, Layout};
5 + use ratatui::style::{Color, Modifier, Style};
6 + use ratatui::text::{Line, Span};
7 + use ratatui::widgets::{Block, Borders, Paragraph, Row, Table};
8 +
9 + use super::App;
10 +
11 + pub fn render(frame: &mut Frame, app: &App) {
12 + let area = frame.area();
13 +
14 + let item_title = app
15 + .keys_item_title
16 + .as_deref()
17 + .unwrap_or("License Keys");
18 +
19 + let title = Line::from(vec![
20 + Span::styled(" Makenot.work ", Style::default().add_modifier(Modifier::BOLD)),
21 + Span::raw(" -- "),
22 + Span::styled(item_title, Style::default().add_modifier(Modifier::BOLD)),
23 + Span::raw(" -- Keys "),
24 + ]);
25 +
26 + let block = Block::default()
27 + .title(title)
28 + .borders(Borders::ALL)
29 + .border_style(Style::default().fg(Color::Gray));
30 +
31 + let inner = block.inner(area);
32 + frame.render_widget(block, area);
33 +
34 + let chunks = Layout::vertical([
35 + Constraint::Length(1), // spacer
36 + Constraint::Length(1), // section header
37 + Constraint::Min(3), // key list
38 + Constraint::Length(1), // status line
39 + Constraint::Length(1), // keybindings
40 + ])
41 + .split(inner);
42 +
43 + // Section header
44 + let count = app.license_keys.len();
45 + let header = Paragraph::new(Line::from(vec![
46 + Span::raw(" "),
47 + Span::styled("Keys", Style::default().add_modifier(Modifier::BOLD)),
48 + if count == 0 {
49 + Span::raw("")
50 + } else {
51 + Span::raw(format!(" ({})", count))
52 + },
53 + ]));
54 + frame.render_widget(header, chunks[1]);
55 +
56 + // Key list
57 + if app.loading {
58 + let loading = Paragraph::new(" Loading...");
59 + frame.render_widget(loading, chunks[2]);
60 + } else if app.license_keys.is_empty() {
61 + let empty = Paragraph::new(" No license keys. Press [g] to generate one.");
62 + frame.render_widget(empty, chunks[2]);
63 + } else {
64 + render_key_table(frame, app, chunks[2]);
65 + }
66 +
67 + // Status line
68 + if let Some(ref status) = app.keys_status {
69 + let style = if status.starts_with("Error") {
70 + Style::default().fg(Color::Red)
71 + } else {
72 + Style::default().fg(Color::Green)
73 + };
74 + let status_line = Paragraph::new(Line::from(vec![
75 + Span::raw(" "),
76 + Span::styled(status.as_str(), style),
77 + ]));
78 + frame.render_widget(status_line, chunks[3]);
79 + }
80 +
81 + // Keybindings
82 + let mut key_spans = vec![
83 + Span::raw(" "),
84 + Span::styled("[j/k]", Style::default().add_modifier(Modifier::BOLD)),
85 + Span::raw(" Nav "),
86 + Span::styled("[g]", Style::default().add_modifier(Modifier::BOLD)),
87 + Span::raw(" Generate "),
88 + ];
89 +
90 + if !app.license_keys.is_empty() {
91 + key_spans.extend([
92 + Span::styled("[x]", Style::default().add_modifier(Modifier::BOLD)),
93 + Span::raw(" Revoke "),
94 + ]);
95 + }
96 +
97 + key_spans.extend([
98 + Span::styled("[r]", Style::default().add_modifier(Modifier::BOLD)),
99 + Span::raw(" Refresh "),
100 + Span::styled("[Esc]", Style::default().add_modifier(Modifier::BOLD)),
101 + Span::raw(" Back"),
102 + ]);
103 +
104 + let keys = Paragraph::new(Line::from(key_spans)).style(Style::default().fg(Color::DarkGray));
105 + frame.render_widget(keys, chunks[4]);
106 + }
107 +
108 + fn render_key_table(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
109 + let selected = app.selected_index;
110 + let rows: Vec<Row> = app
111 + .license_keys
112 + .iter()
113 + .enumerate()
114 + .map(|(i, key)| {
115 + let style = if i == selected {
116 + Style::default()
117 + .bg(Color::DarkGray)
118 + .fg(Color::White)
119 + .add_modifier(Modifier::BOLD)
120 + } else {
121 + Style::default()
122 + };
123 +
124 + let status = if key.is_revoked {
125 + "Revoked"
126 + } else {
127 + "Active"
128 + };
129 + let activations = match key.max_activations {
130 + Some(max) => format!("{}/{}", key.activation_count, max),
131 + None => key.activation_count.to_string(),
132 + };
133 + let date = key.created_at.get(..10).unwrap_or(&key.created_at);
134 +
135 + Row::new(vec![
136 + format!(" {}", key.key_code),
137 + status.to_string(),
138 + activations,
139 + date.to_string(),
140 + ])
141 + .style(style)
142 + })
143 + .collect();
144 +
145 + let header = Row::new(vec![" Key", "Status", "Activations", "Created"])
146 + .style(
147 + Style::default()
148 + .fg(Color::DarkGray)
149 + .add_modifier(Modifier::BOLD),
150 + )
151 + .bottom_margin(0);
152 +
153 + let widths = [
154 + Constraint::Min(24),
155 + Constraint::Length(10),
156 + Constraint::Length(12),
157 + Constraint::Length(12),
158 + ];
159 +
160 + let table = Table::new(rows, widths).header(header);
161 + frame.render_widget(table, area);
162 + }
@@ -0,0 +1,2172 @@
1 + //! TUI application state and event loop.
2 +
3 + pub mod analytics;
4 + pub mod blog;
5 + pub mod home;
6 + pub mod item;
7 + pub mod keys;
8 + pub mod project;
9 + pub mod promo;
10 + pub mod settings;
11 + pub mod upload;
12 +
13 + use std::path::PathBuf;
14 +
15 + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
16 + use ratatui::Terminal;
17 + use ratatui::backend::CrosstermBackend;
18 + use tokio::sync::mpsc;
19 +
20 + use crate::api::{
21 + AnalyticsData, BlogPost, CreatorStats, Item, ItemDetail, LicenseKey, MnwApiClient, Project,
22 + PromoCode, SshKeyInfo, StorageInfo, Transaction, UserInfo, Version,
23 + };
24 + use crate::ssh::terminal::TerminalHandle;
25 + use crate::staging::{self, StagedFile};
26 +
27 + /// Events sent to the TUI event loop.
28 + pub enum AppEvent {
29 + /// Raw input bytes from the SSH channel.
30 + Input(Vec<u8>),
31 + /// Terminal resize.
32 + Resize(u16, u16),
33 + /// Data loaded from the API.
34 + DataLoaded(DataPayload),
35 + }
36 +
37 + /// Payload variants for async data loading.
38 + pub enum DataPayload {
39 + Home {
40 + projects: Vec<Project>,
41 + stats: CreatorStats,
42 + },
43 + ProjectItems {
44 + items: Vec<Item>,
45 + },
46 + StagedFiles {
47 + files: Vec<StagedFile>,
48 + storage: Option<StorageInfo>,
49 + },
50 + PublishResult {
51 + filename: String,
52 + success: bool,
53 + error: Option<String>,
54 + },
55 + ItemDetail {
56 + detail: ItemDetail,
57 + versions: Vec<Version>,
58 + },
59 + ItemUpdated {
60 + detail: ItemDetail,
61 + },
62 + ItemDeleted,
63 + ItemActionError {
64 + error: String,
65 + },
66 + /// Signal to reload the project items list after a mutation.
67 + ProjectReload {
68 + project_idx: usize,
69 + },
70 + BlogPosts {
71 + posts: Vec<BlogPost>,
72 + },
73 + BlogCreated,
74 + PromoCodes {
75 + codes: Vec<PromoCode>,
76 + },
77 + LicenseKeys {
78 + keys: Vec<LicenseKey>,
79 + },
80 + GenericSuccess {
81 + message: String,
82 + },
83 + GenericError {
84 + error: String,
85 + },
86 + Analytics {
87 + data: AnalyticsData,
88 + },
89 + Transactions {
90 + txs: Vec<Transaction>,
91 + },
92 + ExportCsv {
93 + csv: String,
94 + row_count: usize,
95 + },
96 + Settings {
97 + keys: Vec<SshKeyInfo>,
98 + storage: Option<StorageInfo>,
99 + },
100 + }
101 +
102 + /// Handle for sending events to a running TUI session.
103 + #[derive(Clone)]
104 + pub struct AppHandle {
105 + tx: mpsc::Sender<AppEvent>,
106 + }
107 +
108 + impl AppHandle {
109 + pub async fn send_input(&self, data: &[u8]) {
110 + let _ = self.tx.send(AppEvent::Input(data.to_vec())).await;
111 + }
112 +
113 + pub async fn send_resize(&self, cols: u16, rows: u16) {
114 + let _ = self.tx.send(AppEvent::Resize(cols, rows)).await;
115 + }
116 + }
117 +
118 + /// Active screen in the TUI.
119 + enum Screen {
120 + Home,
121 + /// Project detail view. Index is into `app.projects`.
122 + Project(usize),
123 + /// Upload management screen.
124 + Upload,
125 + /// Item detail view. Stores (project_index, item_id).
126 + Item(usize, String),
127 + /// Blog post list for a project. Stores (project_index, project_id).
128 + Blog(usize, String),
129 + /// Promo code management.
130 + Promo,
131 + /// License key management for an item. Stores (project_index, item_id).
132 + Keys(usize, String),
133 + /// Analytics dashboard.
134 + Analytics,
135 + /// Settings screen (profile, storage, SSH keys).
136 + Settings,
137 + }
138 +
139 + /// User-editable metadata for a staged file.
140 + #[derive(Debug, Clone, Default)]
141 + pub struct FileMetadata {
142 + pub title: Option<String>,
143 + pub project_idx: Option<usize>,
144 + pub project_name: Option<String>,
145 + pub price_cents: i32,
146 + }
147 +
148 + /// Which field is being edited on the upload screen.
149 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
150 + pub(crate) enum EditField {
151 + Title,
152 + Project,
153 + Price,
154 + }
155 +
156 + /// Steps for creating a blog post.
157 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
158 + pub(crate) enum BlogCreateStep {
159 + Title,
160 + Body,
161 + }
162 +
163 + /// Steps for creating a promo code.
164 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
165 + pub(crate) enum PromoCreateStep {
166 + Code,
167 + Discount,
168 + }
169 +
170 + /// Application state shared across screens.
171 + pub struct App {
172 + pub user: UserInfo,
173 + pub projects: Vec<Project>,
174 + pub stats: Option<CreatorStats>,
175 + pub items: Vec<Item>,
176 + pub selected_index: usize,
177 + pub loading: bool,
178 + pub staged_files: Vec<StagedFile>,
179 + pub storage_info: Option<StorageInfo>,
180 + pub file_metadata: Vec<FileMetadata>,
181 + pub upload_status: Option<String>,
182 + pub editing_field: Option<EditField>,
183 + pub edit_buffer: String,
184 + pub publishing: bool,
185 + pub item_detail: Option<ItemDetail>,
186 + pub item_versions: Vec<Version>,
187 + pub item_status: Option<String>,
188 + pub item_editing: Option<item::ItemEditField>,
189 + // Blog
190 + pub blog_posts: Vec<BlogPost>,
191 + pub blog_project_title: Option<String>,
192 + pub blog_status: Option<String>,
193 + pub blog_creating: bool,
194 + pub blog_create_step: Option<BlogCreateStep>,
195 + pub blog_create_title: String,
196 + // Promo codes
197 + pub promo_codes: Vec<PromoCode>,
198 + pub promo_status: Option<String>,
199 + pub promo_editing_step: Option<PromoCreateStep>,
200 + pub promo_create_code: String,
201 + pub promo_create_discount: String,
202 + // License keys
203 + pub license_keys: Vec<LicenseKey>,
204 + pub keys_item_title: Option<String>,
205 + pub keys_status: Option<String>,
206 + // Analytics
207 + pub analytics_data: Option<AnalyticsData>,
208 + pub analytics_range: String,
209 + pub analytics_status: Option<String>,
210 + pub analytics_show_transactions: bool,
211 + pub transactions: Vec<Transaction>,
212 + // Settings
213 + pub ssh_keys: Vec<SshKeyInfo>,
214 + pub settings_status: Option<String>,
215 + }
216 +
217 + impl App {
218 + fn new(user: UserInfo) -> Self {
219 + Self {
220 + user,
221 + projects: Vec::new(),
222 + stats: None,
223 + items: Vec::new(),
224 + selected_index: 0,
225 + loading: true,
226 + staged_files: Vec::new(),
227 + storage_info: None,
228 + file_metadata: Vec::new(),
229 + upload_status: None,
230 + editing_field: None,
231 + edit_buffer: String::new(),
232 + publishing: false,
233 + item_detail: None,
234 + item_versions: Vec::new(),
235 + item_status: None,
236 + item_editing: None,
237 + blog_posts: Vec::new(),
238 + blog_project_title: None,
239 + blog_status: None,
240 + blog_creating: false,
241 + blog_create_step: None,
242 + blog_create_title: String::new(),
243 + promo_codes: Vec::new(),
244 + promo_status: None,
245 + promo_editing_step: None,
246 + promo_create_code: String::new(),
247 + promo_create_discount: String::new(),
248 + license_keys: Vec::new(),
249 + keys_item_title: None,
250 + keys_status: None,
251 + analytics_data: None,
252 + analytics_range: "30d".to_string(),
253 + analytics_status: None,
254 + analytics_show_transactions: false,
255 + transactions: Vec::new(),
256 + ssh_keys: Vec::new(),
257 + settings_status: None,
258 + }
259 + }
260 +
261 + fn list_len(&self, screen: &Screen) -> usize {
262 + match screen {
263 + Screen::Home => self.projects.len(),
264 + Screen::Project(_) => self.items.len(),
265 + Screen::Upload => self.staged_files.len(),
266 + Screen::Item(..) => self.item_versions.len(),
267 + Screen::Blog(..) => self.blog_posts.len(),
268 + Screen::Promo => self.promo_codes.len(),
269 + Screen::Keys(..) => self.license_keys.len(),
270 + Screen::Analytics => self.transactions.len(),
271 + Screen::Settings => self.ssh_keys.len(),
272 + }
273 + }
274 +
275 + fn move_up(&mut self, screen: &Screen) {
276 + if self.selected_index > 0 {
277 + self.selected_index -= 1;
278 + } else {
279 + // Wrap to bottom
280 + let len = self.list_len(screen);
281 + if len > 0 {
282 + self.selected_index = len - 1;
283 + }
284 + }
285 + }
286 +
287 + fn move_down(&mut self, screen: &Screen) {
288 + let len = self.list_len(screen);
289 + if len > 0 {
290 + if self.selected_index < len - 1 {
291 + self.selected_index += 1;
292 + } else {
293 + // Wrap to top
294 + self.selected_index = 0;
295 + }
296 + }
297 + }
298 +
299 + /// Ensure file_metadata vec matches staged_files length.
300 + fn sync_metadata(&mut self) {
301 + while self.file_metadata.len() < self.staged_files.len() {
302 + let idx = self.file_metadata.len();
303 + let title = staging::derive_title(&self.staged_files[idx].filename);
304 + self.file_metadata.push(FileMetadata {
305 + title: Some(title),
306 + ..Default::default()
307 + });
308 + }
309 + self.file_metadata.truncate(self.staged_files.len());
310 + }
311 + }
312 +
313 + /// Launch the TUI event loop in a background task.
314 + pub fn launch(
315 + writer: TerminalHandle,
316 + user: UserInfo,
317 + cols: u16,
318 + rows: u16,
319 + session_handle: russh::server::Handle,
320 + channel_id: russh::ChannelId,
321 + api: MnwApiClient,
322 + staging_dir: PathBuf,
323 + ) -> anyhow::Result<AppHandle> {
324 + let backend = CrosstermBackend::new(writer);
325 + let mut terminal = Terminal::new(backend)?;
326 + terminal.resize(ratatui::layout::Rect::new(0, 0, cols, rows))?;
327 +
328 + let (tx, mut rx) = mpsc::channel::<AppEvent>(64);
329 + let handle = AppHandle { tx: tx.clone() };
330 +
331 + // Kick off initial data load
332 + let user_id = user.user_id.clone();
333 + let api_clone = api.clone();
334 + let tx_clone = tx.clone();
335 + tokio::spawn(async move {
336 + load_home_data(&api_clone, &user_id, &tx_clone).await;
337 + });
338 +
339 + tokio::spawn(async move {
340 + let mut app = App::new(user);
341 + let mut screen = Screen::Home;
342 + let staging_dir = staging_dir;
343 +
344 + // Initial render (loading state)
345 + if let Err(e) = terminal.draw(|frame| home::render(frame, &app)) {
346 + tracing::error!(error = ?e, "initial render failed");
347 + return;
348 + }
349 +
350 + while let Some(event) = rx.recv().await {
351 + match event {
352 + AppEvent::Input(data) => {
353 + if let Some(key) = parse_key(&data) {
354 + // Global quit
355 + if (key.modifiers.contains(KeyModifiers::CONTROL)
356 + && key.code == KeyCode::Char('c'))
357 + || matches!(
358 + (&screen, key.code),
359 + (Screen::Home, KeyCode::Char('q') | KeyCode::Char('Q'))
360 + )
361 + {
362 + tracing::info!(user = %app.user.username, "user quit");
363 + let _ = session_handle.close(channel_id).await;
364 + return;
365 + }
366 +
367 + match screen {
368 + Screen::Home => {
369 + handle_home_input(
370 + key, &mut app, &mut screen, &api, &tx, &staging_dir,
371 + )
372 + .await;
373 + }
374 + Screen::Project(_) => {
375 + handle_project_input(
376 + key, &mut app, &mut screen, &api, &tx,
377 + )
378 + .await;
379 + }
380 + Screen::Upload => {
381 + handle_upload_input(
382 + key, &mut app, &mut screen, &api, &tx, &staging_dir,
383 + )
384 + .await;
385 + }
386 + Screen::Item(..) => {
387 + handle_item_input(
388 + key, &mut app, &mut screen, &api, &tx,
389 + )
390 + .await;
391 + }
392 + Screen::Blog(..) => {
393 + handle_blog_input(
394 + key, &mut app, &mut screen, &api, &tx,
395 + )
396 + .await;
397 + }
398 + Screen::Promo => {
399 + handle_promo_input(
400 + key, &mut app, &mut screen, &api, &tx,
401 + )
402 + .await;
403 + }
404 + Screen::Keys(..) => {
405 + handle_keys_input(
406 + key, &mut app, &mut screen, &api, &tx,
407 + )
408 + .await;
409 + }
410 + Screen::Analytics => {
411 + handle_analytics_input(
412 + key, &mut app, &mut screen, &api, &tx,
413 + )
414 + .await;
415 + }
416 + Screen::Settings => {
417 + handle_settings_input(
418 + key, &mut app, &mut screen, &api, &tx,
419 + )
420 + .await;
421 + }
422 + }
423 + }
424 + }
425 + AppEvent::Resize(cols, rows) => {
426 + let rect = ratatui::layout::Rect::new(0, 0, cols, rows);
427 + let _ = terminal.resize(rect);
428 + }
429 + AppEvent::DataLoaded(payload) => match payload {
430 + DataPayload::Home { projects, stats } => {
431 + app.projects = projects;
432 + app.stats = Some(stats);
433 + app.loading = false;
434 + app.selected_index = 0;
435 + }
436 + DataPayload::ProjectItems { items } => {
437 + app.items = items;
438 + app.loading = false;
439 + app.selected_index = 0;
440 + }
441 + DataPayload::StagedFiles { files, storage } => {
442 + app.staged_files = files;
443 + if let Some(s) = storage {
444 + app.storage_info = Some(s);
445 + }
446 + app.sync_metadata();
447 + app.loading = false;
448 + if app.selected_index >= app.staged_files.len() && !app.staged_files.is_empty() {
449 + app.selected_index = app.staged_files.len() - 1;
450 + }
451 + }
452 + DataPayload::ItemDetail { detail, versions } => {
453 + app.item_detail = Some(detail);
454 + app.item_versions = versions;
455 + app.loading = false;
456 + app.selected_index = 0;
457 + }
458 + DataPayload::ItemUpdated { detail } => {
459 + app.item_detail = Some(detail);
460 + app.item_status = Some("Updated".to_string());
461 + app.item_editing = None;
462 + app.edit_buffer.clear();
463 + }
464 + DataPayload::ItemDeleted => {
465 + app.item_status = Some("Deleted".to_string());
466 + // Navigate back to project view
467 + if let Screen::Item(project_idx, _) = &screen {
468 + let pidx = *project_idx;
469 + screen = Screen::Project(pidx);
470 + app.item_detail = None;
471 + app.item_versions.clear();
472 + app.item_status = None;
473 + app.selected_index = 0;
474 + app.loading = true;
475 +
476 + if let Some(p) = app.projects.get(pidx) {
477 + let api = api.clone();
478 + let project_id = p.id.clone();
479 + let user_id = app.user.user_id.clone();
480 + let tx = tx.clone();
481 + tokio::spawn(async move {
482 + load_project_items(&api, &project_id, &user_id, &tx).await;
483 + });
484 + }
485 + }
486 + }
487 + DataPayload::ItemActionError { error } => {
488 + app.item_status = Some(format!("Error: {}", error));
489 + }
490 + DataPayload::BlogPosts { posts } => {
491 + app.blog_posts = posts;
492 + app.loading = false;
493 + app.selected_index = 0;
494 + }
495 + DataPayload::BlogCreated => {
496 + app.blog_creating = false;
497 + app.blog_create_step = None;
498 + app.blog_create_title.clear();
499 + app.edit_buffer.clear();
500 + app.blog_status = Some("Post created".to_string());
Lines truncated