Skip to main content

max / makenotwork

Format the tree with rustfmt and add the lint + supply-chain gates Two halves: the mechanical reformat, and four new Sando gate kinds that can only exist once the tree is clean. The reformat is plain rustfmt defaults across all 16 crates -- 7481 hunks. No rustfmt.toml is added: the hand-formatted style the tree carried is not reachable by configuration (use_small_heuristics = "Max" closed only 27% of the gap on sando), so matching it was not an option and defaults are what any contributor's editor produces. Every crate was tested after the pass. New gate kinds: - clippy: cargo clippy --all-targets -- -D warnings over every [[test_target]]. - fmt: cargo fmt --check over the same list. Declared first in the host tier because it needs no compilation, so a formatting red returns in seconds. - cargo_audit / cargo_deny: config-gated. A target qualifies only if it carries .cargo/audit.toml or deny.toml. Both tools are meaningless without a triaged posture -- four crates here fail cargo audit purely for lack of a file recording which transitive advisories were reviewed -- and a permanently red gate teaches everyone to ignore it. All four share cargo_test's conventions: per-target log banners, stop at the first red target with the crate named, one deadline across the whole gate, and fail closed if nothing qualified rather than passing over zero work. Supply-chain fixes so the gates start green: deny.toml allows Apache-2.0 WITH LLVM-exception (cranelift via yara-x, permissive, no copyleft), and drops five ignores cargo-deny reported as advisory-not-detected -- the rustls-webpki trio cleared when d7a50c5a dropped the EOL rustls 0.21 stack, the git2 pair when git2 was bumped. .cargo/audit.toml is pruned to match. docengine, s3-storage, pom-contract and livechat now carry license = "MIT", which cargo deny check licenses required and deny.toml's own header already recorded as the planned cleanup. MIT per the licensing strategy: reusable infra is MIT, products are PolyForm-Noncommercial.
Author: Max Johnson <me@maxj.phd> · 2026-07-21 20:45 UTC
Commit: a64ec6301841ca95f8ee33df49f74dd16dad21c8
Parent: 87063f6
743 files changed, +50567 insertions, -27977 deletions
M mnw-cli/src/api.rs +151 -62
@@ -657,11 +657,7 @@ impl MnwApiClient {
657 657 }
658 658
659 659 /// Publish an item (set is_public=true).
660 - pub async fn publish_item(
661 - &self,
662 - user_id: &str,
663 - item_id: &str,
664 - ) -> anyhow::Result<ItemDetail> {
660 + pub async fn publish_item(&self, user_id: &str, item_id: &str) -> anyhow::Result<ItemDetail> {
665 661 let url = format!(
666 662 "{}/api/internal/creator/items/{}/publish",
667 663 self.base_url, item_id
@@ -679,11 +675,7 @@ impl MnwApiClient {
679 675 }
680 676
681 677 /// Unpublish an item (set is_public=false).
682 - pub async fn unpublish_item(
683 - &self,
684 - user_id: &str,
685 - item_id: &str,
686 - ) -> anyhow::Result<ItemDetail> {
678 + pub async fn unpublish_item(&self, user_id: &str, item_id: &str) -> anyhow::Result<ItemDetail> {
687 679 let url = format!(
688 680 "{}/api/internal/creator/items/{}/unpublish",
689 681 self.base_url, item_id
@@ -895,9 +887,9 @@ impl MnwApiClient {
895 887 ) -> anyhow::Result<()> {
896 888 use tokio::io::AsyncReadExt;
897 889
898 - let mut file = tokio::fs::File::open(file_path).await.map_err(|e| {
899 - anyhow::anyhow!("opening {} for upload: {e}", file_path.display())
900 - })?;
890 + let mut file = tokio::fs::File::open(file_path)
891 + .await
892 + .map_err(|e| anyhow::anyhow!("opening {} for upload: {e}", file_path.display()))?;
901 893
902 894 let mut completed: Vec<(i32, String)> = Vec::with_capacity(start.part_count as usize);
903 895 let mut uploaded: u64 = 0;
@@ -982,8 +974,10 @@ impl MnwApiClient {
982 974 }
983 975 Err(e) => {
984 976 if attempt >= 3 {
985 - return Err(anyhow::Error::new(e)
986 - .context(format!("part {} failed after {attempt} attempts", part.part_number)));
977 + return Err(anyhow::Error::new(e).context(format!(
978 + "part {} failed after {attempt} attempts",
979 + part.part_number
980 + )));
987 981 }
988 982 e.to_string()
989 983 }
@@ -1217,11 +1211,7 @@ impl MnwApiClient {
1217 1211 }
1218 1212
1219 1213 /// Revoke a license key.
1220 - pub async fn revoke_license_key(
1221 - &self,
1222 - user_id: &str,
1223 - key_id: &str,
1224 - ) -> anyhow::Result<()> {
1214 + pub async fn revoke_license_key(&self, user_id: &str, key_id: &str) -> anyhow::Result<()> {
1225 1215 let url = format!(
1226 1216 "{}/api/internal/creator/keys/{}/revoke",
1227 1217 self.base_url, key_id
@@ -1241,11 +1231,7 @@ impl MnwApiClient {
1241 1231 // ── Analytics ──
1242 1232
1243 1233 /// Get analytics data (timeseries, period comparison, top projects).
1244 - pub async fn get_analytics(
1245 - &self,
1246 - user_id: &str,
1247 - range: &str,
1248 - ) -> anyhow::Result<AnalyticsData> {
1234 + pub async fn get_analytics(&self, user_id: &str, range: &str) -> anyhow::Result<AnalyticsData> {
1249 1235 let url = format!("{}/api/internal/creator/analytics", self.base_url);
1250 1236 let resp = self
1251 1237 .http
@@ -1348,61 +1334,117 @@ impl MnwApiClient {
1348 1334
1349 1335 // ── Tags ──
1350 1336
1351 - pub async fn list_item_tags(&self, user_id: &str, item_id: &str) -> anyhow::Result<Vec<TagInfo>> {
1352 - let url = format!("{}/api/internal/creator/items/{}/tags", self.base_url, item_id);
1353 - let resp = self.http.get(&url).bearer_auth(&self.service_token)
1337 + pub async fn list_item_tags(
1338 + &self,
1339 + user_id: &str,
1340 + item_id: &str,
1341 + ) -> anyhow::Result<Vec<TagInfo>> {
1342 + let url = format!(
1343 + "{}/api/internal/creator/items/{}/tags",
1344 + self.base_url, item_id
1345 + );
1346 + let resp = self
1347 + .http
1348 + .get(&url)
1349 + .bearer_auth(&self.service_token)
1354 1350 .header("X-MNW-Actor", self.actor_header())
1355 - .query(&[("user_id", user_id)]).send().await?;
1351 + .query(&[("user_id", user_id)])
1352 + .send()
1353 + .await?;
1356 1354 json_response(resp, "list_item_tags").await
1357 1355 }
1358 1356
1359 1357 pub async fn search_tags(&self, query: &str) -> anyhow::Result<Vec<TagInfo>> {
1360 1358 let url = format!("{}/api/internal/tags/search", self.base_url);
1361 - let resp = self.http.get(&url).bearer_auth(&self.service_token)
1359 + let resp = self
1360 + .http
1361 + .get(&url)
1362 + .bearer_auth(&self.service_token)
1362 1363 .header("X-MNW-Actor", self.actor_header())
1363 - .query(&[("q", query)]).send().await?;
1364 + .query(&[("q", query)])
1365 + .send()
1366 + .await?;
1364 1367 json_response(resp, "search_tags").await
1365 1368 }
1366 1369
1367 - pub async fn add_item_tag(&self, user_id: &str, item_id: &str, tag_id: &str) -> anyhow::Result<()> {
1370 + pub async fn add_item_tag(
1371 + &self,
1372 + user_id: &str,
1373 + item_id: &str,
1374 + tag_id: &str,
1375 + ) -> anyhow::Result<()> {
1368 1376 let url = format!("{}/api/internal/creator/items/tags", self.base_url);
1369 - let resp = self.http.post(&url).bearer_auth(&self.service_token)
1377 + let resp = self
1378 + .http
1379 + .post(&url)
1380 + .bearer_auth(&self.service_token)
1370 1381 .header("X-MNW-Actor", self.actor_header())
1371 1382 .json(&serde_json::json!({"user_id": user_id, "item_id": item_id, "tag_id": tag_id}))
1372 - .send().await?;
1383 + .send()
1384 + .await?;
1373 1385 empty_response(resp, "add_item_tag").await
1374 1386 }
1375 1387
1376 1388 // Unused by the TUI today; kept so the client mirrors the full
1377 1389 // /api/internal surface rather than only the paths one caller happens to hit.
1378 1390 #[allow(dead_code)]
1379 - pub async fn remove_item_tag(&self, user_id: &str, item_id: &str, tag_id: &str) -> anyhow::Result<()> {
1391 + pub async fn remove_item_tag(
1392 + &self,
1393 + user_id: &str,
1394 + item_id: &str,
1395 + tag_id: &str,
1396 + ) -> anyhow::Result<()> {
1380 1397 let url = format!("{}/api/internal/creator/items/tags/remove", self.base_url);
1381 - let resp = self.http.post(&url).bearer_auth(&self.service_token)
1398 + let resp = self
1399 + .http
1400 + .post(&url)
1401 + .bearer_auth(&self.service_token)
1382 1402 .header("X-MNW-Actor", self.actor_header())
1383 1403 .json(&serde_json::json!({"user_id": user_id, "item_id": item_id, "tag_id": tag_id}))
1384 - .send().await?;
1404 + .send()
1405 + .await?;
1385 1406 empty_response(resp, "remove_item_tag").await
1386 1407 }
1387 1408
1388 1409 // ── Broadcast ──
1389 1410
1390 - pub async fn send_broadcast(&self, user_id: &str, subject: &str, body: &str) -> anyhow::Result<BroadcastResult> {
1411 + pub async fn send_broadcast(
1412 + &self,
1413 + user_id: &str,
1414 + subject: &str,
1415 + body: &str,
1416 + ) -> anyhow::Result<BroadcastResult> {
1391 1417 let url = format!("{}/api/internal/creator/broadcast", self.base_url);
1392 - let resp = self.http.post(&url).bearer_auth(&self.service_token)
1418 + let resp = self
1419 + .http
1420 + .post(&url)
1421 + .bearer_auth(&self.service_token)
1393 1422 .header("X-MNW-Actor", self.actor_header())
1394 1423 .json(&serde_json::json!({"user_id": user_id, "subject": subject, "body": body}))
1395 - .send().await?;
1424 + .send()
1425 + .await?;
1396 1426 json_response(resp, "send_broadcast").await
1397 1427 }
1398 1428
1399 1429 // ── Tiers ──
1400 1430
1401 - pub async fn list_tiers(&self, user_id: &str, project_id: &str) -> anyhow::Result<Vec<TierInfo>> {
1402 - let url = format!("{}/api/internal/creator/projects/{}/tiers", self.base_url, project_id);
1403 - let resp = self.http.get(&url).bearer_auth(&self.service_token)
1431 + pub async fn list_tiers(
1432 + &self,
1433 + user_id: &str,
1434 + project_id: &str,
1435 + ) -> anyhow::Result<Vec<TierInfo>> {
1436 + let url = format!(
1437 + "{}/api/internal/creator/projects/{}/tiers",
1438 + self.base_url, project_id
1439 + );
1440 + let resp = self
1441 + .http
1442 + .get(&url)
1443 + .bearer_auth(&self.service_token)
1404 1444 .header("X-MNW-Actor", self.actor_header())
1405 - .query(&[("user_id", user_id)]).send().await?;
1445 + .query(&[("user_id", user_id)])
1446 + .send()
1447 + .await?;
1406 1448 json_response(resp, "list_tiers").await
1407 1449 }
1408 1450
@@ -1410,28 +1452,54 @@ impl MnwApiClient {
1410 1452
1411 1453 pub async fn list_collections(&self, user_id: &str) -> anyhow::Result<Vec<CollectionInfo>> {
1412 1454 let url = format!("{}/api/internal/creator/collections", self.base_url);
1413 - let resp = self.http.get(&url).bearer_auth(&self.service_token)
1455 + let resp = self
1456 + .http
1457 + .get(&url)
1458 + .bearer_auth(&self.service_token)
1414 1459 .header("X-MNW-Actor", self.actor_header())
1415 - .query(&[("user_id", user_id)]).send().await?;
1460 + .query(&[("user_id", user_id)])
1461 + .send()
1462 + .await?;
1416 1463 json_response(resp, "list_collections").await
1417 1464 }
1418 1465
1419 1466 #[allow(dead_code)]
1420 - pub async fn create_collection(&self, user_id: &str, slug: &str, title: &str) -> anyhow::Result<serde_json::Value> {
1467 + pub async fn create_collection(
1468 + &self,
1469 + user_id: &str,
1470 + slug: &str,
1471 + title: &str,
1472 + ) -> anyhow::Result<serde_json::Value> {
1421 1473 let url = format!("{}/api/internal/creator/collections", self.base_url);
1422 - let resp = self.http.post(&url).bearer_auth(&self.service_token)
1474 + let resp = self
1475 + .http
1476 + .post(&url)
1477 + .bearer_auth(&self.service_token)
1423 1478 .header("X-MNW-Actor", self.actor_header())
1424 1479 .json(&serde_json::json!({"user_id": user_id, "slug": slug, "title": title}))
1425 - .send().await?;
1480 + .send()
1481 + .await?;
1426 1482 json_response(resp, "create_collection").await
1427 1483 }
1428 1484
1429 1485 #[allow(dead_code)]
1430 - pub async fn delete_collection(&self, user_id: &str, collection_id: &str) -> anyhow::Result<()> {
1431 - let url = format!("{}/api/internal/creator/collections/{}", self.base_url, collection_id);
1432 - let resp = self.http.delete(&url).bearer_auth(&self.service_token)
1486 + pub async fn delete_collection(
1487 + &self,
1488 + user_id: &str,
1489 + collection_id: &str,
1490 + ) -> anyhow::Result<()> {
1491 + let url = format!(
1492 + "{}/api/internal/creator/collections/{}",
1493 + self.base_url, collection_id
1494 + );
1495 + let resp = self
1496 + .http
1497 + .delete(&url)
1498 + .bearer_auth(&self.service_token)
1433 1499 .header("X-MNW-Actor", self.actor_header())
1434 - .query(&[("user_id", user_id)]).send().await?;
1500 + .query(&[("user_id", user_id)])
1501 + .send()
1502 + .await?;
1435 1503 empty_response(resp, "delete_collection").await
1436 1504 }
1437 1505
@@ -1439,36 +1507,57 @@ impl MnwApiClient {
1439 1507
1440 1508 pub async fn get_domain(&self, user_id: &str) -> anyhow::Result<Option<DomainInfo>> {
1441 1509 let url = format!("{}/api/internal/creator/domain", self.base_url);
1442 - let resp = self.http.get(&url).bearer_auth(&self.service_token)
1510 + let resp = self
1511 + .http
1512 + .get(&url)
1513 + .bearer_auth(&self.service_token)
1443 1514 .header("X-MNW-Actor", self.actor_header())
1444 - .query(&[("user_id", user_id)]).send().await?;
1515 + .query(&[("user_id", user_id)])
1516 + .send()
1517 + .await?;
1445 1518 let val: serde_json::Value = json_response(resp, "get_domain").await?;
1446 - if val.is_null() { return Ok(None); }
1519 + if val.is_null() {
1520 + return Ok(None);
1521 + }
1447 1522 Ok(serde_json::from_value(val).ok())
1448 1523 }
1449 1524
1450 1525 pub async fn add_domain(&self, user_id: &str, domain: &str) -> anyhow::Result<DomainInfo> {
1451 1526 let url = format!("{}/api/internal/creator/domain", self.base_url);
1452 - let resp = self.http.post(&url).bearer_auth(&self.service_token)
1527 + let resp = self
1528 + .http
1529 + .post(&url)
1530 + .bearer_auth(&self.service_token)
1453 1531 .header("X-MNW-Actor", self.actor_header())
1454 1532 .json(&serde_json::json!({"user_id": user_id, "domain": domain}))
1455 - .send().await?;
1533 + .send()
1534 + .await?;
1456 1535 json_response(resp, "add_domain").await
1457 1536 }
1458 1537
1459 1538 pub async fn verify_domain(&self, user_id: &str) -> anyhow::Result<DomainVerifyResult> {
1460 1539 let url = format!("{}/api/internal/creator/domain/verify", self.base_url);
1461 - let resp = self.http.post(&url).bearer_auth(&self.service_token)
1540 + let resp = self
1541 + .http
1542 + .post(&url)
1543 + .bearer_auth(&self.service_token)
1462 1544 .header("X-MNW-Actor", self.actor_header())
1463 - .query(&[("user_id", user_id)]).send().await?;
1545 + .query(&[("user_id", user_id)])
1546 + .send()
1547 + .await?;
1464 1548 json_response(resp, "verify_domain").await
1465 1549 }
1466 1550
1467 1551 pub async fn remove_domain(&self, user_id: &str) -> anyhow::Result<()> {
1468 1552 let url = format!("{}/api/internal/creator/domain", self.base_url);
1469 - let resp = self.http.delete(&url).bearer_auth(&self.service_token)
1553 + let resp = self
1554 + .http
1555 + .delete(&url)
1556 + .bearer_auth(&self.service_token)
1470 1557 .header("X-MNW-Actor", self.actor_header())
1471 - .query(&[("user_id", user_id)]).send().await?;
1558 + .query(&[("user_id", user_id)])
1559 + .send()
1560 + .await?;
1472 1561 empty_response(resp, "remove_domain").await
1473 1562 }
1474 1563 }
@@ -29,11 +29,7 @@ pub(crate) fn sanitize_api_error(e: &anyhow::Error) -> String {
29 29 }
30 30
31 31 /// Execute a non-interactive command and return the output bytes.
32 - pub async fn execute(
33 - command_line: &str,
34 - user: &UserInfo,
35 - api: &MnwApiClient,
36 - ) -> Vec<u8> {
32 + pub async fn execute(command_line: &str, user: &UserInfo, api: &MnwApiClient) -> Vec<u8> {
37 33 let parts: Vec<&str> = command_line.split_whitespace().collect();
38 34 if parts.is_empty() {
39 35 return help_text();
@@ -115,11 +111,15 @@ async fn cmd_project_create(
115 111 if !user.can_create_projects {
116 112 return b"Error: your account cannot create projects. Upgrade your tier at makenot.work/pricing\r\n".to_vec();
117 113 }
118 - match api.create_project(&user.user_id, title, project_type, description).await {
114 + match api
115 + .create_project(&user.user_id, title, project_type, description)
116 + .await
117 + {
119 118 Ok(p) => format!(
120 119 "Created project: {} (slug: {}, type: {})\r\n",
121 120 p.title, p.slug, p.project_type
122 - ).into_bytes(),
121 + )
122 + .into_bytes(),
123 123 Err(e) => format!("Error: {}\r\n", sanitize_api_error(&e)).into_bytes(),
124 124 }
125 125 }
@@ -157,12 +157,7 @@ async fn cmd_projects(user: &UserInfo, api: &MnwApiClient, json: bool) -> Vec<u8
157 157 }
158 158 }
159 159
160 - async fn cmd_analytics(
161 - user: &UserInfo,
162 - api: &MnwApiClient,
163 - range: &str,
164 - json: bool,
165 - ) -> Vec<u8> {
160 + async fn cmd_analytics(user: &UserInfo, api: &MnwApiClient, range: &str, json: bool) -> Vec<u8> {
166 161 match api.get_analytics(&user.user_id, range).await {
167 162 Ok(data) => {
168 163 if json {
@@ -285,12 +280,7 @@ async fn cmd_promo_list(user: &UserInfo, api: &MnwApiClient, json: bool) -> Vec<
285 280 }
286 281 }
287 282
288 - async fn cmd_promo_create(
289 - user: &UserInfo,
290 - api: &MnwApiClient,
291 - code: &str,
292 - pct: &str,
293 - ) -> Vec<u8> {
283 + async fn cmd_promo_create(user: &UserInfo, api: &MnwApiClient, code: &str, pct: &str) -> Vec<u8> {
294 284 if code.is_empty() {
295 285 return b"Usage: promo create CODE DISCOUNT_PCT\r\n".to_vec();
296 286 }
@@ -304,12 +294,7 @@ async fn cmd_promo_create(
304 294 }
305 295 }
306 296
307 - async fn cmd_blog_list(
308 - user: &UserInfo,
309 - api: &MnwApiClient,
310 - slug: &str,
311 - json: bool,
312 - ) -> Vec<u8> {
297 + async fn cmd_blog_list(user: &UserInfo, api: &MnwApiClient, slug: &str, json: bool) -> Vec<u8> {
313 298 if slug.is_empty() {
314 299 return b"Usage: blog list <project-slug>\r\n".to_vec();
315 300 }
@@ -359,10 +344,15 @@ async fn cmd_broadcast(user: &UserInfo, api: &MnwApiClient, subject: &str, body:
359 344 if subject.is_empty() || body.is_empty() {
360 345 return b"Usage: broadcast --subject \"Subject line\" --body \"Body text\"\r\n\
361 346 \x20 Aliases: -s, -b\r\n\
362 - \x20 Sends an email to all your followers (1/24h limit).\r\n".to_vec();
347 + \x20 Sends an email to all your followers (1/24h limit).\r\n"
348 + .to_vec();
363 349 }
364 350 match api.send_broadcast(&user.user_id, subject, body).await {
365 - Ok(result) => format!("Broadcast sent to {} followers.\r\n", result.recipient_count).into_bytes(),
351 + Ok(result) => format!(
352 + "Broadcast sent to {} followers.\r\n",
353 + result.recipient_count
354 + )
355 + .into_bytes(),
366 356 Err(e) => format!("Error: {}\r\n", sanitize_api_error(&e)).into_bytes(),
367 357 }
368 358 }
@@ -404,9 +394,10 @@ async fn cmd_domain_show(user: &UserInfo, api: &MnwApiClient) -> Vec<u8> {
404 394 let status = if d.verified { "verified" } else { "pending" };
405 395 let mut out = format!("Domain: {} ({})\r\n", d.domain, status);
406 396 if !d.verified
407 - && let Some(ref instr) = d.instructions {
408 - out.push_str(&format!("{}\r\n", instr));
409 - }
397 + && let Some(ref instr) = d.instructions
398 + {
399 + out.push_str(&format!("{}\r\n", instr));
400 + }
410 401 out.into_bytes()
411 402 }
412 403 Ok(None) => b"No custom domain configured.\r\nUsage: domain add <domain>\r\n".to_vec(),
@@ -455,7 +446,12 @@ pub async fn execute_pipe_upload(
455 446 anyhow::bail!("no data received on stdin");
456 447 }
457 448
458 - let ext = upload.filename.rsplit('.').next().unwrap_or("").to_lowercase();
449 + let ext = upload
450 + .filename
451 + .rsplit('.')
452 + .next()
453 + .unwrap_or("")
454 + .to_lowercase();
459 455 let classification = staging::classify_extension(&ext)
460 456 .ok_or_else(|| anyhow::anyhow!(
461 457 "unsupported file type: .{ext}\r\nSupported: mp3, wav, flac, ogg, m4a, aac, zip, dmg, exe, appimage, deb, clap, vst3"
@@ -463,27 +459,32 @@ pub async fn execute_pipe_upload(
463 459
464 460 // Find project by slug
465 461 let projects = api.get_projects(&user.user_id).await?;
466 - let project = projects.iter()
462 + let project = projects
463 + .iter()
467 464 .find(|p| p.slug == upload.project_slug)
468 465 .ok_or_else(|| anyhow::anyhow!("project not found: {}", upload.project_slug))?;
469 466
470 467 // Create item
471 - let item = api.create_item(
472 - &user.user_id,
473 - &project.id,
474 - &upload.title,
475 - classification.item_type,
476 - upload.price_cents,
477 - ).await?;
468 + let item = api
469 + .create_item(
470 + &user.user_id,
471 + &project.id,
472 + &upload.title,
473 + classification.item_type,
474 + upload.price_cents,
475 + )
476 + .await?;
478 477
479 478 // Presign upload
480 - let presign = api.presign_upload(
481 - &user.user_id,
482 - &item.item_id,
483 - classification.file_type,
484 - &upload.filename,
485 - classification.content_type,
486 - ).await?;
479 + let presign = api
480 + .presign_upload(
481 + &user.user_id,
482 + &item.item_id,
483 + classification.file_type,
484 + &upload.filename,
485 + classification.content_type,
486 + )
487 + .await?;
487 488
488 489 // Upload data directly to S3
489 490 let resp = reqwest::Client::new()
@@ -503,7 +504,8 @@ pub async fn execute_pipe_upload(
503 504 &item.item_id,
504 505 classification.file_type,
505 506 &presign.s3_key,
506 - ).await?;
507 + )
508 + .await?;
507 509
508 510 // Publish
509 511 api.publish_item(&user.user_id, &item.item_id).await?;
@@ -557,27 +559,28 @@ pub fn help_text() -> Vec<u8> {
557 559 fn extract_flag(parts: &[&str], flags: &[&str]) -> Option<String> {
558 560 for (i, part) in parts.iter().enumerate() {
559 561 if flags.contains(part)
560 - && let Some(&next) = parts.get(i + 1) {
561 - // If the value starts with a quote, collect until closing quote
562 - if next.starts_with('"') || next.starts_with('\'') {
563 - let quote = next.as_bytes()[0] as char;
564 - let stripped = &next[1..];
565 - if stripped.ends_with(quote) {
566 - return Some(stripped[..stripped.len() - 1].to_string());
567 - }
568 - let mut collected = stripped.to_string();
569 - for &subsequent in &parts[i + 2..] {
570 - collected.push(' ');
571 - if subsequent.ends_with(quote) {
572 - collected.push_str(&subsequent[..subsequent.len() - 1]);
573 - return Some(collected);
574 - }
575 - collected.push_str(subsequent);
562 + && let Some(&next) = parts.get(i + 1)
563 + {
564 + // If the value starts with a quote, collect until closing quote
565 + if next.starts_with('"') || next.starts_with('\'') {
566 + let quote = next.as_bytes()[0] as char;
567 + let stripped = &next[1..];
568 + if stripped.ends_with(quote) {
569 + return Some(stripped[..stripped.len() - 1].to_string());
570 + }
571 + let mut collected = stripped.to_string();
572 + for &subsequent in &parts[i + 2..] {
573 + collected.push(' ');
574 + if subsequent.ends_with(quote) {
575 + collected.push_str(&subsequent[..subsequent.len() - 1]);
576 + return Some(collected);
576 577 }
577 - return Some(collected);
578 + collected.push_str(subsequent);
578 579 }
579 - return Some(next.to_string());
580 + return Some(collected);
580 581 }
582 + return Some(next.to_string());
583 + }
581 584 }
582 585 None
583 586 }
@@ -626,24 +629,43 @@ mod tests {
626 629 #[test]
627 630 fn extract_flag_simple() {
628 631 let parts = vec!["broadcast", "--subject", "Hello", "--body", "World"];
629 - assert_eq!(extract_flag(&parts, &["--subject", "-s"]), Some("Hello".to_string()));
630 - assert_eq!(extract_flag(&parts, &["--body", "-b"]), Some("World".to_string()));
632 + assert_eq!(
633 + extract_flag(&parts, &["--subject", "-s"]),
634 + Some("Hello".to_string())
635 + );
636 + assert_eq!(
637 + extract_flag(&parts, &["--body", "-b"]),
638 + Some("World".to_string())
639 + );
631 640 }
632 641
633 642 #[test]
634 643 fn extract_flag_short() {
635 644 let parts = vec!["broadcast", "-s", "Hi", "-b", "There"];
636 - assert_eq!(extract_flag(&parts, &["--subject", "-s"]), Some("Hi".to_string()));
645 + assert_eq!(
646 + extract_flag(&parts, &["--subject", "-s"]),
647 + Some("Hi".to_string())
648 + );
637 649 }
638 650
639 651 #[test]
640 652 fn extract_flag_quoted_multiword() {
641 - let parts = vec!["broadcast", "--subject", "\"Hello", "everyone\"", "--body", "\"text\""];
653 + let parts = vec![
654 + "broadcast",
655 + "--subject",
656 + "\"Hello",
657 + "everyone\"",
658 + "--body",
659 + "\"text\"",
660 + ];
642 661 assert_eq!(
643 662 extract_flag(&parts, &["--subject", "-s"]),
644 663 Some("Hello everyone".to_string())
645 664 );
646 - assert_eq!(extract_flag(&parts, &["--body", "-b"]), Some("text".to_string()));
665 + assert_eq!(
666 + extract_flag(&parts, &["--body", "-b"]),
667 + Some("text".to_string())
668 + );
647 669 }
648 670
649 671 #[test]
@@ -26,8 +26,8 @@ impl Config {
26 26 .parse()
27 27 .map_err(|_| anyhow::anyhow!("Invalid SSH_PORT"))?;
28 28
29 - let api_url = std::env::var("MNW_API_URL")
30 - .unwrap_or_else(|_| "http://localhost:3000".to_string());
29 + let api_url =
30 + std::env::var("MNW_API_URL").unwrap_or_else(|_| "http://localhost:3000".to_string());
31 31
32 32 let service_token = std::env::var("MNW_SERVICE_TOKEN")
33 33 .map_err(|_| anyhow::anyhow!("MNW_SERVICE_TOKEN is required"))?;
@@ -40,8 +40,7 @@ impl Config {
40 40 .unwrap_or_else(|_| "/var/lib/mnw-cli/staging".to_string())
41 41 .into();
42 42
43 - let git_user = std::env::var("GIT_SUDO_USER")
44 - .unwrap_or_else(|_| "git".to_string());
43 + let git_user = std::env::var("GIT_SUDO_USER").unwrap_or_else(|_| "git".to_string());
45 44
46 45 Ok(Config {
47 46 port,
@@ -22,9 +22,9 @@ mod tui;
22 22
23 23 use std::sync::Arc;
24 24
25 + use russh::MethodKind;
25 26 use russh::keys::{self, Algorithm, PrivateKey, ssh_key};
26 27 use russh::server::Server as _;
27 - use russh::MethodKind;
28 28 use tokio::signal;
29 29 use tracing_subscriber::EnvFilter;
30 30
@@ -128,8 +128,7 @@ fn load_or_generate_host_key(path: &std::path::Path) -> anyhow::Result<PrivateKe
128 128 // the standalone rand crate; russh 0.62 dropped its rand_core RC pin, so
129 129 // both now agree on rand 0.10 and the workaround is gone. This is the
130 130 // same call russh itself uses to generate keys.
131 - let key =
132 - ssh_key::private::PrivateKey::random(&mut rand::rng(), Algorithm::Ed25519)?;
131 + let key = ssh_key::private::PrivateKey::random(&mut rand::rng(), Algorithm::Ed25519)?;
133 132 // Save to disk in OpenSSH format
134 133 let pem = key.to_openssh(ssh_key::LineEnding::LF)?;
135 134 if let Some(parent) = path.parent() {
M mnw-cli/src/ota.rs +41 -21
@@ -10,7 +10,7 @@
10 10
11 11 use std::path::PathBuf;
12 12
13 - use anyhow::{bail, Context, Result};
13 + use anyhow::{Context, Result, bail};
14 14 use synckit_client::{SyncKitClient, SyncKitConfig};
15 15
16 16 const DEFAULT_SERVER: &str = "https://makenot.work";
@@ -156,10 +156,16 @@ fn parse_args(flags: &[String]) -> Result<PublishArgs> {
156 156 let arch = arch.ok_or_else(|| missing("--arch"))?;
157 157
158 158 if !ALLOWED_TARGETS.contains(&target.as_str()) {
159 - bail!("invalid --target '{target}'. Allowed: {}", ALLOWED_TARGETS.join(", "));
159 + bail!(
160 + "invalid --target '{target}'. Allowed: {}",
161 + ALLOWED_TARGETS.join(", ")
162 + );
160 163 }
161 164 if !ALLOWED_ARCHS.contains(&arch.as_str()) {
162 - bail!("invalid --arch '{arch}'. Allowed: {}", ALLOWED_ARCHS.join(", "));
165 + bail!(
166 + "invalid --arch '{arch}'. Allowed: {}",
167 + ALLOWED_ARCHS.join(", ")
168 + );
163 169 }
164 170
165 171 Ok(PublishArgs {
@@ -340,10 +346,12 @@ async fn authenticate_oauth(client: &SyncKitClient, key: &str) -> Result<()> {
340 346 open_browser(&url);
341 347 println!(" waiting for the authorization redirect (5 min timeout)...");
342 348
343 - let (code, got_state) =
344 - tokio::time::timeout(std::time::Duration::from_secs(300), wait_for_code(&listener))
345 - .await
346 - .context("timed out waiting for OAuth authorization")??;
349 + let (code, got_state) = tokio::time::timeout(
350 + std::time::Duration::from_secs(300),
351 + wait_for_code(&listener),
352 + )
353 + .await
354 + .context("timed out waiting for OAuth authorization")??;
347 355
348 356 if got_state.as_deref() != Some(state.as_str()) {
349 357 bail!("OAuth state mismatch — aborting (possible CSRF or a stale redirect)");
@@ -359,9 +367,7 @@ async fn authenticate_oauth(client: &SyncKitClient, key: &str) -> Result<()> {
359 367 /// Accept localhost connections until one carries an OAuth `code` (ignoring
360 368 /// incidental requests like `/favicon.ico`), reply with a small page, and return
361 369 /// `(code, state)`.
362 - async fn wait_for_code(
363 - listener: &tokio::net::TcpListener,
364 - ) -> Result<(String, Option<String>)> {
370 + async fn wait_for_code(listener: &tokio::net::TcpListener) -> Result<(String, Option<String>)> {
365 371 use tokio::io::AsyncReadExt;
366 372 loop {
367 373 let (mut sock, _) = listener.accept().await.context("accept failed")?;
@@ -420,7 +426,11 @@ async fn respond(sock: &mut tokio::net::TcpStream, message: &str) -> std::io::Re
420 426
421 427 /// Best-effort browser open; the URL is also printed for manual use (e.g. over SSH).
422 428 fn open_browser(url: &str) {
423 - let opener = if cfg!(target_os = "macos") { "open" } else { "xdg-open" };
429 + let opener = if cfg!(target_os = "macos") {
430 + "open"
431 + } else {
432 + "xdg-open"
433 + };
424 434 let _ = std::process::Command::new(opener)
425 435 .arg(url)
426 436 .stdout(std::process::Stdio::null())
@@ -434,16 +444,26 @@ mod tests {
434 444
435 445 fn base_flags() -> Vec<String> {
436 446 [
437 - "--slug", "goingson",
438 - "--version", "0.4.1",
439 - "--target", "darwin",
440 - "--arch", "aarch64",
441 - "--artifact", "/tmp/x",
442 - "--email", "me@example.com",
443 - "--password", "pw",
444 - "--api-key", "ak",
445 - "--key", "sdk",
446 - "--server", "https://example.test",
447 + "--slug",
448 + "goingson",
449 + "--version",
450 + "0.4.1",
451 + "--target",
452 + "darwin",
453 + "--arch",
454 + "aarch64",
455 + "--artifact",
456 + "/tmp/x",
457 + "--email",
458 + "me@example.com",
459 + "--password",
460 + "pw",
461 + "--api-key",
462 + "ak",
463 + "--key",
464 + "sdk",
465 + "--server",
466 + "https://example.test",
447 467 ]
448 468 .iter()
449 469 .map(|s| s.to_string())
@@ -5,8 +5,8 @@
5 5 //! parses that command, and spawns the git subprocess with its stdin/stdout/stderr piped
6 6 //! through the SSH channel.
7 7
8 - use russh::server::Handle;
9 8 use russh::ChannelId;
9 + use russh::server::Handle;
10 10 use tokio::io::AsyncReadExt;
11 11 use tokio::process::{Child, Command};
12 12
@@ -87,13 +87,14 @@ impl russh::server::Handler for MnwHandler {
87 87 ) -> Result<Auth, Self::Error> {
88 88 // Per-IP rate limiting: reject early if threshold exceeded
89 89 if let Some(addr) = self.peer_addr
90 - && !self.rate_limiter.check(addr.ip()) {
91 - tracing::warn!(peer = %addr, "auth rate limit exceeded");
92 - return Ok(Auth::Reject {
93 - proceed_with_methods: None,
94 - partial_success: false,
95 - });
96 - }
90 + && !self.rate_limiter.check(addr.ip())
91 + {
92 + tracing::warn!(peer = %addr, "auth rate limit exceeded");
93 + return Ok(Auth::Reject {
94 + proceed_with_methods: None,
95 + partial_success: false,
96 + });
97 + }
97 98
98 99 let fingerprint = key.fingerprint(HashAlg::Sha256).to_string();
99 100 tracing::debug!(%fingerprint, peer = ?self.peer_addr, "key offered");
@@ -137,11 +138,7 @@ impl russh::server::Handler for MnwHandler {
137 138 }
138 139 }
139 140
140 - async fn auth_publickey(
141 - &mut self,
142 - _user: &str,
143 - _key: &PublicKey,
144 - ) -> Result<Auth, Self::Error> {
141 + async fn auth_publickey(&mut self, _user: &str, _key: &PublicKey) -> Result<Auth, Self::Error> {
145 142 // If auth_publickey_offered accepted, the user is already stored.
146 143 if self.user.is_some() {
147 144 Ok(Auth::Accept)
@@ -265,11 +262,7 @@ impl russh::server::Handler for MnwHandler {
265 262 };
266 263
267 264 // Take the stored channel for this ID
268 - let channel = self
269 - .channels
270 - .lock()
271 - .await
272 - .remove(&channel_id);
265 + let channel = self.channels.lock().await.remove(&channel_id);
273 266
274 267 let Some(channel) = channel else {
275 268 tracing::error!("no stored channel for SFTP subsystem");
@@ -335,7 +328,14 @@ impl russh::server::Handler for MnwHandler {
335 328 // gets git group ownership. Avoids systemd security
336 329 // restrictions that block sudo child processes.
337 330 match tokio::process::Command::new("git")
338 - .args(["init", "--bare", "--shared=group", "-b", "main", &auth.repo_path])
331 + .args([
332 + "init",
333 + "--bare",
334 + "--shared=group",
335 + "-b",
336 + "main",
337 + &auth.repo_path,
338 + ])
339 339 .stdout(std::process::Stdio::null())
340 340 .stderr(std::process::Stdio::null())
341 341 .status()
@@ -355,7 +355,8 @@ impl russh::server::Handler for MnwHandler {
355 355 }
356 356 Ok(s) => {
357 357 tracing::error!(path = %auth.repo_path, code = ?s.code(), "git init --bare failed");
358 - let msg = bytes::Bytes::from("fatal: failed to create repository\r\n");
358 + let msg =
359 + bytes::Bytes::from("fatal: failed to create repository\r\n");
359 360 let _ = handle.extended_data(channel, 1, msg).await;
360 361 let _ = handle.exit_status_request(channel, 1).await;
361 362 let _ = handle.eof(channel).await;
@@ -389,9 +390,7 @@ impl russh::server::Handler for MnwHandler {
389 390 }
390 391 Err(e) => {
391 392 tracing::error!(error = ?e, "failed to spawn git process");
392 - let msg = bytes::Bytes::from(
393 - "fatal: internal error\r\n".to_string(),
394 - );
393 + let msg = bytes::Bytes::from("fatal: internal error\r\n".to_string());
395 394 let _ = handle.data(channel, msg).await;
396 395 let _ = handle.exit_status_request(channel, 1).await;
397 396 let _ = handle.eof(channel).await;
@@ -423,10 +422,22 @@ impl russh::server::Handler for MnwHandler {
423 422 let mut i = 1;
424 423 while i < parts.len() {
425 424 match parts[i] {
426 - "--filename" | "-f" if i + 1 < parts.len() => { filename = parts[i + 1].to_string(); i += 1; }
427 - "--project" | "-p" if i + 1 < parts.len() => { project_slug = parts[i + 1].to_string(); i += 1; }
428 - "--title" | "-t" if i + 1 < parts.len() => { title = parts[i + 1].to_string(); i += 1; }
429 - "--price" if i + 1 < parts.len() => { price_cents = parts[i + 1].parse().unwrap_or(0); i += 1; }
425 + "--filename" | "-f" if i + 1 < parts.len() => {
426 + filename = parts[i + 1].to_string();
427 + i += 1;
428 + }
429 + "--project" | "-p" if i + 1 < parts.len() => {
430 + project_slug = parts[i + 1].to_string();
431 + i += 1;
432 + }
433 + "--title" | "-t" if i + 1 < parts.len() => {
434 + title = parts[i + 1].to_string();
435 + i += 1;
436 + }
437 + "--price" if i + 1 < parts.len() => {
438 + price_cents = parts[i + 1].parse().unwrap_or(0);
439 + i += 1;
440 + }
430 441 _ => {}
431 442 }
432 443 i += 1;
@@ -448,14 +459,17 @@ impl russh::server::Handler for MnwHandler {
448 459 title = staging::derive_title(&filename);
449 460 }
450 461
451 - self.pipe_uploads.insert(channel, PipeUpload {
452 - user: user.clone(),
453 - filename,
454 - project_slug,
455 - title,
456 - price_cents,
457 - data: Vec::new(),
458 - });
462 + self.pipe_uploads.insert(
463 + channel,
464 + PipeUpload {
465 + user: user.clone(),
466 + filename,
467 + project_slug,
468 + title,
469 + price_cents,
470 + data: Vec::new(),
471 + },
472 + );
459 473 session.channel_success(channel)?;
460 474 return Ok(());
461 475 }
@@ -521,7 +535,10 @@ impl russh::server::Handler for MnwHandler {
521 535 let result = crate::commands::execute_pipe_upload(&api, upload).await;
522 536 let (msg, exit_code) = match result {
523 537 Ok(msg) => (msg, 0),
524 - Err(e) => (format!("Error: {}\r\n", crate::commands::sanitize_api_error(&e)), 1),
538 + Err(e) => (
539 + format!("Error: {}\r\n", crate::commands::sanitize_api_error(&e)),
540 + 1,
541 + ),
525 542 };
526 543 let _ = handle.data(channel, bytes::Bytes::from(msg)).await;
527 544 let _ = handle.exit_status_request(channel, exit_code).await;
@@ -11,7 +11,7 @@ use russh_sftp::protocol::{
11 11 Attrs, Data, File, FileAttributes, Handle, Name, OpenFlags, Status, StatusCode, Version,
12 12 };
13 13
14 - use crate::staging::{self, is_allowed_extension, sanitize_filename, STAGING_QUOTA_BYTES};
14 + use crate::staging::{self, STAGING_QUOTA_BYTES, is_allowed_extension, sanitize_filename};
15 15
16 16 /// SFTP session handler for a single authenticated user.
17 17 pub struct SftpSession {
@@ -260,7 +260,9 @@ impl russh_sftp::server::Handler for SftpSession {
260 260 }
261 261
262 262 if pflags.contains(OpenFlags::READ) {
263 - let file = tokio::fs::File::open(&file_path).await.map_err(|_| StatusCode::NoSuchFile)?;
263 + let file = tokio::fs::File::open(&file_path)
264 + .await
265 + .map_err(|_| StatusCode::NoSuchFile)?;
264 266 let handle = self.alloc_handle();
265 267 self.open_files.insert(
266 268 handle.clone(),
@@ -20,10 +20,7 @@ impl TerminalHandle {
20 20 ///
21 21 /// The relay task forwards buffered data to the SSH session's
22 22 /// channel via `session_handle.data()`.
23 - pub fn new(
24 - session_handle: russh::server::Handle,
25 - channel_id: russh::ChannelId,
26 - ) -> Self {
23 + pub fn new(session_handle: russh::server::Handle, channel_id: russh::ChannelId) -> Self {
27 24 let (tx, mut rx) = mpsc::channel::<Vec<u8>>(64);
28 25
29 26 tokio::spawn(async move {
@@ -55,7 +55,9 @@ pub async fn list_staged_files(dir: &Path) -> Vec<StagedFile> {
55 55 files.push(StagedFile {
56 56 filename,
57 57 size: metadata.len(),
58 - modified: metadata.modified().unwrap_or(std::time::SystemTime::UNIX_EPOCH),
58 + modified: metadata
59 + .modified()
60 + .unwrap_or(std::time::SystemTime::UNIX_EPOCH),
59 61 classification,
60 62 });
61 63 }
@@ -23,7 +23,10 @@ pub fn render(frame: &mut Frame, app: &App) {
23 23 };
24 24
25 25 let title = Line::from(vec![
26 - Span::styled(" Makenot.work ", Style::default().add_modifier(Modifier::BOLD)),
26 + Span::styled(
27 + " Makenot.work ",
28 + Style::default().add_modifier(Modifier::BOLD),
29 + ),
27 30 Span::raw(" -- "),
28 31 Span::styled("Analytics", Style::default().add_modifier(Modifier::BOLD)),
29 32 Span::raw(format!(" ({}) ", range_label)),
@@ -41,7 +44,7 @@ pub fn render(frame: &mut Frame, app: &App) {
41 44 Constraint::Length(1), // spacer
42 45 Constraint::Length(3), // stat cards
43 46 Constraint::Length(1), // spacer
44 - Constraint::Min(6), // chart or transactions
47 + Constraint::Min(6), // chart or transactions
45 48 Constraint::Length(1), // status
46 49 Constraint::Length(1), // keybindings
47 50 ])
@@ -107,17 +110,24 @@ fn render_stat_cards(frame: &mut Frame, app: &App, area: ratatui::layout::Rect)
107 110 (
108 111 "Revenue",
109 112 format::format_cents(data.as_ref().map(|d| d.current_revenue_cents).unwrap_or(0)),
110 - data.as_ref().map(|d| pct_change(d.current_revenue_cents, d.previous_revenue_cents)),
113 + data.as_ref()
114 + .map(|d| pct_change(d.current_revenue_cents, d.previous_revenue_cents)),
111 115 ),
112 116 (
113 117 "Sales",
114 - data.as_ref().map(|d| d.current_sales.to_string()).unwrap_or_else(|| "--".into()),
115 - data.as_ref().map(|d| pct_change(d.current_sales, d.previous_sales)),
118 + data.as_ref()
119 + .map(|d| d.current_sales.to_string())
120 + .unwrap_or_else(|| "--".into()),
121 + data.as_ref()
122 + .map(|d| pct_change(d.current_sales, d.previous_sales)),
116 123 ),
117 124 (
118 125 "Followers",
119 - data.as_ref().map(|d| d.current_followers.to_string()).unwrap_or_else(|| "--".into()),
120 - data.as_ref().map(|d| pct_change(d.current_followers, d.previous_followers)),
126 + data.as_ref()
127 + .map(|d| d.current_followers.to_string())
128 + .unwrap_or_else(|| "--".into()),
129 + data.as_ref()
130 + .map(|d| pct_change(d.current_followers, d.previous_followers)),
121 131 ),
122 132 ];
123 133
@@ -149,10 +159,10 @@ fn render_stat_cards(frame: &mut Frame, app: &App, area: ratatui::layout::Rect)
149 159 fn render_chart_and_projects(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
150 160 let chunks = Layout::vertical([
151 161 Constraint::Length(1), // chart header
152 - Constraint::Min(4), // chart
162 + Constraint::Min(4), // chart
153 163 Constraint::Length(1), // spacer
154 164 Constraint::Length(1), // projects header
155 - Constraint::Min(2), // projects table
165 + Constraint::Min(2), // projects table
156 166 ])
157 167 .split(area);
158 168
@@ -169,7 +179,13 @@ fn render_chart_and_projects(frame: &mut Frame, app: &App, area: ratatui::layout
169 179 let empty = Paragraph::new(" No revenue data for this period.");
170 180 frame.render_widget(empty, chunks[1]);
171 181 } else {
172 - let max_val = data.buckets.iter().map(|b| b.revenue_cents).max().unwrap_or(1).max(1);
182 + let max_val = data
183 + .buckets
184 + .iter()
185 + .map(|b| b.revenue_cents)
186 + .max()
187 + .unwrap_or(1)
188 + .max(1);
173 189
174 190 let bars: Vec<Bar> = data
175 191 .buckets
@@ -208,7 +224,10 @@ fn render_chart_and_projects(frame: &mut Frame, app: &App, area: ratatui::layout
208 224 // Projects header
209 225 let proj_header = Paragraph::new(Line::from(vec![
210 226 Span::raw(" "),
211 - Span::styled("Top Projects", Style::default().add_modifier(Modifier::BOLD)),
227 + Span::styled(
228 + "Top Projects",
229 + Style::default().add_modifier(Modifier::BOLD),
230 + ),
212 231 ]));
213 232 frame.render_widget(proj_header, chunks[3]);
214 233
@@ -238,14 +257,17 @@ fn render_chart_and_projects(frame: &mut Frame, app: &App, area: ratatui::layout
238 257 fn render_transactions(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
239 258 let chunks = Layout::vertical([
240 259 Constraint::Length(1), // header
241 - Constraint::Min(3), // table
260 + Constraint::Min(3), // table
242 261 ])
243 262 .split(area);
244 263
245 264 let count = app.transactions.len();
246 265 let header = Paragraph::new(Line::from(vec![
247 266 Span::raw(" "),
248 - Span::styled("Transactions", Style::default().add_modifier(Modifier::BOLD)),
267 + Span::styled(
268 + "Transactions",
269 + Style::default().add_modifier(Modifier::BOLD),
270 + ),
249 271 if count == 0 {
250 272 Span::raw("")
251 273 } else {
@@ -284,7 +306,13 @@ fn render_transactions(frame: &mut Frame, app: &App, area: ratatui::layout::Rect
284 306 Constraint::Length(12),
285 307 ];
286 308
287 - widgets::render_table(frame, chunks[1], &[" Item", "Amount", "Status", "Date"], &widths, rows);
309 + widgets::render_table(
310 + frame,
311 + chunks[1],
312 + &[" Item", "Amount", "Status", "Date"],
313 + &widths,
314 + rows,
315 + );
288 316 }
289 317 }
290 318
@@ -12,13 +12,13 @@ use super::widgets;
12 12 pub fn render(frame: &mut Frame, app: &App) {
13 13 let area = frame.area();
14 14
15 - let project_title = app
16 - .blog_project_title
17 - .as_deref()
18 - .unwrap_or("Blog");
15 + let project_title = app.blog_project_title.as_deref().unwrap_or("Blog");
19 16
20 17 let title = Line::from(vec![
21 - Span::styled(" Makenot.work ", Style::default().add_modifier(Modifier::BOLD)),
18 + Span::styled(
19 + " Makenot.work ",
20 + Style::default().add_modifier(Modifier::BOLD),
21 + ),
22 22 Span::raw(" -- "),
23 23 Span::styled(project_title, Style::default().add_modifier(Modifier::BOLD)),
24 24 Span::raw(" -- Blog "),
@@ -35,7 +35,7 @@ pub fn render(frame: &mut Frame, app: &App) {
35 35 let chunks = Layout::vertical([
36 36 Constraint::Length(1), // spacer
37 37 Constraint::Length(1), // section header
38 - Constraint::Min(3), // post list
38 + Constraint::Min(3), // post list
39 39 Constraint::Length(1), // status line
40 40 Constraint::Length(1), // keybindings
41 41 ])
@@ -139,5 +139,11 @@ fn render_post_table(frame: &mut Frame, app: &App, area: ratatui::layout::Rect)
139 139 Constraint::Length(12),
140 140 ];
141 141
142 - widgets::render_table(frame, area, &[" Title", "Slug", "Status", "Created"], &widths, rows);
142 + widgets::render_table(
143 + frame,
144 + area,
145 + &[" Title", "Slug", "Status", "Created"],
146 + &widths,
147 + rows,
148 + );
143 149 }
@@ -13,7 +13,10 @@ pub fn render(frame: &mut Frame, app: &App) {
13 13 let area = frame.area();
14 14
15 15 let title = Line::from(vec![
16 - Span::styled(" Makenot.work ", Style::default().add_modifier(Modifier::BOLD)),
16 + Span::styled(
17 + " Makenot.work ",
18 + Style::default().add_modifier(Modifier::BOLD),
19 + ),
17 20 Span::raw(" -- "),
18 21 Span::styled("Collections", Style::default().add_modifier(Modifier::BOLD)),
19 22 Span::raw(" "),
@@ -30,7 +33,7 @@ pub fn render(frame: &mut Frame, app: &App) {
30 33 let chunks = Layout::vertical([
31 34 Constraint::Length(1), // spacer
32 35 Constraint::Length(1), // section header
33 - Constraint::Min(3), // list
36 + Constraint::Min(3), // list
34 37 Constraint::Length(1), // status line
35 38 Constraint::Length(1), // keybindings
36 39 ])
@@ -40,7 +43,11 @@ pub fn render(frame: &mut Frame, app: &App) {
40 43 let header = Paragraph::new(Line::from(vec![
41 44 Span::raw(" "),
42 45 Span::styled("Collections", Style::default().add_modifier(Modifier::BOLD)),
43 - if count == 0 { Span::raw("") } else { Span::raw(format!(" ({})", count)) },
46 + if count == 0 {
47 + Span::raw("")
48 + } else {
49 + Span::raw(format!(" ({})", count))
50 + },
44 51 ]));
45 52 frame.render_widget(header, chunks[1]);
46 53
@@ -48,7 +55,8 @@ pub fn render(frame: &mut Frame, app: &App) {
48 55 let loading = Paragraph::new(" Loading...");
49 56 frame.render_widget(loading, chunks[2]);
50 57 } else if app.collections.is_empty() {
51 - let empty = Paragraph::new(" No collections. Manage collections at makenot.work/dashboard");
58 + let empty =
59 + Paragraph::new(" No collections. Manage collections at makenot.work/dashboard");
52 60 frame.render_widget(empty, chunks[2]);
53 61 } else {
54 62 let rows: Vec<Row> = app
@@ -74,7 +82,13 @@ pub fn render(frame: &mut Frame, app: &App) {
74 82 Constraint::Length(6),
75 83 ];
76 84
77 - widgets::render_table(frame, chunks[2], &[" Title", "Slug", "Status", "Items"], &widths, rows);
85 + widgets::render_table(
86 + frame,
87 + chunks[2],
88 + &[" Title", "Slug", "Status", "Items"],
89 + &widths,
90 + rows,
91 + );
78 92 }
79 93
80 94 if let Some(ref status) = app.collections_status {
@@ -22,7 +22,10 @@ pub fn render(frame: &mut Frame, app: &App) {
22 22 .unwrap_or("No tier");
23 23
24 24 let title = Line::from(vec![
25 - Span::styled(" Makenot.work ", Style::default().add_modifier(Modifier::BOLD)),
25 + Span::styled(
26 + " Makenot.work ",
27 + Style::default().add_modifier(Modifier::BOLD),
28 + ),
26 29 Span::raw(" ── "),
27 30 Span::styled(
28 31 &app.user.username,
@@ -46,7 +49,7 @@ pub fn render(frame: &mut Frame, app: &App) {
46 49 Constraint::Length(3), // stats bar
47 50 Constraint::Length(1), // spacer
48 51 Constraint::Length(1), // section header
49 - Constraint::Min(3), // project list
52 + Constraint::Min(3), // project list
50 53 Constraint::Length(1), // keybindings
51 54 ])
52 55 .split(inner);
@@ -173,6 +176,11 @@ fn render_project_table(frame: &mut Frame, app: &App, area: ratatui::layout::Rec
173 176 Constraint::Length(12),
174 177 ];
175 178
176 - widgets::render_table(frame, area, &[" Title", "Type", "Status", "Items", "Revenue"], &widths, rows);
179 + widgets::render_table(
180 + frame,
181 + area,
182 + &[" Title", "Type", "Status", "Items", "Revenue"],
183 + &widths,
184 + rows,
185 + );
177 186 }
178 -
@@ -10,14 +10,12 @@ pub(crate) async fn handle_analytics_input(
10 10 tx: &mpsc::Sender<AppEvent>,
11 11 ) {
12 12 match key.code {
13 - KeyCode::Char('j') | KeyCode::Down
14 - if app.analytics_show_transactions => {
15 - app.move_down(screen);
16 - }
17 - KeyCode::Char('k') | KeyCode::Up
18 - if app.analytics_show_transactions => {
19 - app.move_up(screen);
20 - }
13 + KeyCode::Char('j') | KeyCode::Down if app.analytics_show_transactions => {
14 + app.move_down(screen);
15 + }
16 + KeyCode::Char('k') | KeyCode::Up if app.analytics_show_transactions => {
17 + app.move_up(screen);
18 + }
21 19 KeyCode::Esc => {
22 20 if app.analytics_show_transactions {
23 21 app.analytics_show_transactions = false;
@@ -10,7 +10,8 @@ pub(crate) async fn handle_blog_input(
10 10 tx: &mpsc::Sender<AppEvent>,
11 11 ) {
12 12 // Cancel pending confirmation on any key other than the confirmation key
13 - if app.confirm_action.is_some() && !matches!(key.code, KeyCode::Char('d') | KeyCode::Char('D')) {
13 + if app.confirm_action.is_some() && !matches!(key.code, KeyCode::Char('d') | KeyCode::Char('D'))
14 + {
14 15 app.confirm_action = None;
15 16 app.blog_status = None;
16 17 }
@@ -20,14 +21,15 @@ pub(crate) async fn handle_blog_input(
20 21 // Ctrl+D in body step: advance to schedule
21 22 if step == BlogCreateStep::Body
22 23 && key.code == KeyCode::Char('d')
23 - && key.modifiers.contains(crossterm::event::KeyModifiers::CONTROL)
24 + && key
25 + .modifiers
26 + .contains(crossterm::event::KeyModifiers::CONTROL)
24 27 {
25 28 app.blog_create_body = app.edit_buffer.clone();
26 29 app.edit_buffer.clear();
27 30 app.blog_create_step = Some(BlogCreateStep::Schedule);
28 - app.blog_status = Some(
29 - "Schedule (YYYY-MM-DDTHH:MM:SSZ or Enter for draft):".to_string(),
30 - );
31 + app.blog_status =
32 + Some("Schedule (YYYY-MM-DDTHH:MM:SSZ or Enter for draft):".to_string());
31 33 return;
32 34 }
33 35
@@ -45,8 +47,9 @@ pub(crate) async fn handle_blog_input(
45 47 app.blog_create_title = app.edit_buffer.clone();
46 48 app.edit_buffer.clear();
47 49 app.blog_create_step = Some(BlogCreateStep::Body);
48 - app.blog_status =
49 - Some("Body (markdown, Enter for newline, Ctrl+D when done):".to_string());
50 + app.blog_status = Some(
51 + "Body (markdown, Enter for newline, Ctrl+D when done):".to_string(),
52 + );
50 53 }
51 54 }
52 55 BlogCreateStep::Body => {
@@ -138,51 +141,51 @@ pub(crate) async fn handle_blog_input(
138 141 app.edit_buffer.clear();
139 142 app.blog_status = Some("Title: _".to_string());
140 143 }
141 - KeyCode::Char('d') | KeyCode::Char('D')
142 - if !app.blog_posts.is_empty() => {
143 - let idx = app.selected_index;
144 - if matches!(app.confirm_action, Some(ConfirmAction::DeleteBlogPost { post_idx }) if post_idx == idx) {
145 - // Confirmed — execute delete
146 - app.confirm_action = None;
147 - let post_id = app.blog_posts[idx].id.clone();
148 - let post_title = app.blog_posts[idx].title.clone();
149 - let user_id = app.user.user_id.clone();
150 - tracing::info!(
151 - user_id = %user_id,
152 - post_id = %post_id,
153 - post_title = %post_title,
154 - "delete blog post confirmed"
155 - );
156 - let api = api.clone();
157 - let tx = tx.clone();
144 + KeyCode::Char('d') | KeyCode::Char('D') if !app.blog_posts.is_empty() => {
145 + let idx = app.selected_index;
146 + if matches!(app.confirm_action, Some(ConfirmAction::DeleteBlogPost { post_idx }) if post_idx == idx)
147 + {
148 + // Confirmed — execute delete
149 + app.confirm_action = None;
150 + let post_id = app.blog_posts[idx].id.clone();
151 + let post_title = app.blog_posts[idx].title.clone();
152 + let user_id = app.user.user_id.clone();
153 + tracing::info!(
154 + user_id = %user_id,
155 + post_id = %post_id,
156 + post_title = %post_title,
157 + "delete blog post confirmed"
158 + );
159 + let api = api.clone();
160 + let tx = tx.clone();
158 161
159 - if let Screen::Blog(_, project_id) = &*screen {
160 - let project_id = project_id.clone();
161 - app.blog_status = Some("Deleting...".to_string());
162 - tokio::spawn(async move {
163 - match api.delete_blog_post(&user_id, &post_id).await {
164 - Ok(()) => {
165 - load_blog_posts(&api, &user_id, &project_id, &tx).await;
166 - }
167 - Err(e) => {
168 - let _ = tx
169 - .send(AppEvent::DataLoaded(DataPayload::GenericError {
170 - error: e.to_string(),
171 - }))
172 - .await;
173 - }
162 + if let Screen::Blog(_, project_id) = &*screen {
163 + let project_id = project_id.clone();
164 + app.blog_status = Some("Deleting...".to_string());
165 + tokio::spawn(async move {
166 + match api.delete_blog_post(&user_id, &post_id).await {
167 + Ok(()) => {
168 + load_blog_posts(&api, &user_id, &project_id, &tx).await;
174 169 }
175 - });
176 - }
177 - } else {
178 - // First press — ask for confirmation
179 - app.confirm_action = Some(ConfirmAction::DeleteBlogPost { post_idx: idx });
180 - app.blog_status = Some(format!(
181 - "Delete '{}'? Press d again to confirm",
182 - app.blog_posts[idx].title
183 - ));
170 + Err(e) => {
171 + let _ = tx
172 + .send(AppEvent::DataLoaded(DataPayload::GenericError {
173 + error: e.to_string(),
174 + }))
175 + .await;
176 + }
177 + }
178 + });
184 179 }
180 + } else {
181 + // First press — ask for confirmation
182 + app.confirm_action = Some(ConfirmAction::DeleteBlogPost { post_idx: idx });
183 + app.blog_status = Some(format!(
184 + "Delete '{}'? Press d again to confirm",
185 + app.blog_posts[idx].title
186 + ));
185 187 }
188 + }
186 189 KeyCode::Char('r') | KeyCode::Char('R') => {
187 190 if let Screen::Blog(_, project_id) = &*screen {
188 191 app.loading = true;
@@ -13,22 +13,21 @@ pub(crate) async fn handle_home_input(
13 13 match key.code {
14 14 KeyCode::Char('j') | KeyCode::Down => app.move_down(screen),
15 15 KeyCode::Char('k') | KeyCode::Up => app.move_up(screen),
16 - KeyCode::Enter
17 - if !app.projects.is_empty() => {
18 - let idx = app.selected_index;
19 - let project_id = app.projects[idx].id.clone();
20 - let user_id = app.user.user_id.clone();
21 - *screen = Screen::Project(idx);
22 - app.items.clear();
23 - app.selected_index = 0;
24 - app.loading = true;
16 + KeyCode::Enter if !app.projects.is_empty() => {
17 + let idx = app.selected_index;
18 + let project_id = app.projects[idx].id.clone();
19 + let user_id = app.user.user_id.clone();
20 + *screen = Screen::Project(idx);
21 + app.items.clear();
22 + app.selected_index = 0;
23 + app.loading = true;
25 24
26 - let api = api.clone();
27 - let tx = tx.clone();
28 - tokio::spawn(async move {
29 - load_project_items(&api, &project_id, &user_id, &tx).await;
30 - });
31 - }
25 + let api = api.clone();
26 + let tx = tx.clone();
27 + tokio::spawn(async move {
28 + load_project_items(&api, &project_id, &user_id, &tx).await;
29 + });
30 + }
32 31 KeyCode::Char('u') | KeyCode::Char('U') => {
33 32 *screen = Screen::Upload;
34 33 app.selected_index = 0;
@@ -22,7 +22,9 @@ pub(crate) async fn handle_item_input(
22 22 }
23 23 KeyCode::Enter => {
24 24 // Add the first search result as a tag
25 - if let (Some(tag), Some(detail)) = (app.tag_search_results.first(), &app.item_detail) {
25 + if let (Some(tag), Some(detail)) =
26 + (app.tag_search_results.first(), &app.item_detail)
27 + {
26 28 let tag_id = tag.id.clone();
27 29 let item_id = detail.id.clone();
28 30 let user_id = app.user.user_id.clone();
@@ -33,16 +35,25 @@ pub(crate) async fn handle_item_input(
33 35 match api.add_item_tag(&user_id, &item_id, &tag_id).await {
34 36 Ok(()) => {
35 37 // Reload tags
36 - let tags = api.list_item_tags(&user_id, &item_id).await.unwrap_or_default();
37 - let _ = tx.send(AppEvent::DataLoaded(DataPayload::ItemTags { tags })).await;
38 - let _ = tx.send(AppEvent::DataLoaded(DataPayload::ItemActionError {
39 - error: format!("Added tag: {}", tag_name), // Reuse error channel for status
40 - })).await;
38 + let tags = api
39 + .list_item_tags(&user_id, &item_id)
40 + .await
41 + .unwrap_or_default();
42 + let _ = tx
43 + .send(AppEvent::DataLoaded(DataPayload::ItemTags { tags }))
44 + .await;
45 + let _ = tx
46 + .send(AppEvent::DataLoaded(DataPayload::ItemActionError {
47 + error: format!("Added tag: {}", tag_name), // Reuse error channel for status
48 + }))
49 + .await;
41 50 }
42 51 Err(e) => {
43 - let _ = tx.send(AppEvent::DataLoaded(DataPayload::ItemActionError {
44 - error: e.to_string(),
45 - })).await;
52 + let _ = tx
53 + .send(AppEvent::DataLoaded(DataPayload::ItemActionError {
54 + error: e.to_string(),
55 + }))
56 + .await;
46 57 }
47 58 }
48 59 });
@@ -58,7 +69,9 @@ pub(crate) async fn handle_item_input(
58 69 let api = api.clone();
59 70 let query = app.edit_buffer.clone();
60 71 let tx = tx.clone();
61 - tokio::spawn(async move { search_tags(&api, &query, &tx).await; });
72 + tokio::spawn(async move {
73 + search_tags(&api, &query, &tx).await;
74 + });
62 75 } else {
63 76 app.tag_search_results.clear();
64 77 }
@@ -69,9 +82,13 @@ pub(crate) async fn handle_item_input(
69 82 let api = api.clone();
70 83 let query = app.edit_buffer.clone();
71 84 let tx = tx.clone();
72 - tokio::spawn(async move { search_tags(&api, &query, &tx).await; });
85 + tokio::spawn(async move {
86 + search_tags(&api, &query, &tx).await;
87 + });
73 88 }
74 - let results_preview: String = app.tag_search_results.iter()
89 + let results_preview: String = app
90 + .tag_search_results
91 + .iter()
75 92 .take(3)
76 93 .map(|t| t.name.as_str())
77 94 .collect::<Vec<_>>()
@@ -79,7 +96,11 @@ pub(crate) async fn handle_item_input(
79 96 app.item_status = Some(format!(
80 97 "Tag: {}_ {}",
81 98 app.edit_buffer,
82 - if results_preview.is_empty() { String::new() } else { format!("[{}]", results_preview) },
99 + if results_preview.is_empty() {
100 + String::new()
101 + } else {
102 + format!("[{}]", results_preview)
103 + },
83 104 ));
84 105 }
85 106 _ => {}
@@ -88,7 +109,8 @@ pub(crate) async fn handle_item_input(
88 109 }
89 110
90 111 // Cancel pending confirmation on any key other than the confirmation key
91 - if app.confirm_action.is_some() && !matches!(key.code, KeyCode::Char('d') | KeyCode::Char('D')) {
112 + if app.confirm_action.is_some() && !matches!(key.code, KeyCode::Char('d') | KeyCode::Char('D'))
113 + {
92 114 app.confirm_action = None;
93 115 app.item_status = None;
94 116 }
@@ -115,7 +137,14 @@ pub(crate) async fn handle_item_input(
115 137 let title = buffer;
116 138 tokio::spawn(async move {
117 139 match api
118 - .update_item(&user_id, &item_id, Some(&title), None, None, None)
140 + .update_item(
141 + &user_id,
142 + &item_id,
143 + Some(&title),
144 + None,
145 + None,
146 + None,
147 + )
119 148 .await
120 149 {
121 150 Ok(d) => {
@@ -161,9 +190,9 @@ pub(crate) async fn handle_item_input(
161 190 {
162 191 Ok(d) => {
163 192 let _ = tx
164 - .send(AppEvent::DataLoaded(
165 - DataPayload::ItemUpdated { detail: d },
166 - ))
193 + .send(AppEvent::DataLoaded(DataPayload::ItemUpdated {
194 + detail: d,
195 + }))
167 196 .await;
168 197 }
169 198 Err(e) => {
@@ -187,9 +216,9 @@ pub(crate) async fn handle_item_input(
187 216 {
188 217 Ok(d) => {
189 218 let _ = tx
190 - .send(AppEvent::DataLoaded(
191 - DataPayload::ItemUpdated { detail: d },
192 - ))
219 + .send(AppEvent::DataLoaded(DataPayload::ItemUpdated {
220 + detail: d,
221 + }))
193 222 .await;
194 223 }
195 224 Err(e) => {
@@ -277,9 +306,7 @@ pub(crate) async fn handle_item_input(
277 306 match api.publish_item(&user_id, &item_id).await {
278 307 Ok(d) => {
279 308 let _ = tx
280 - .send(AppEvent::DataLoaded(DataPayload::ItemUpdated {
281 - detail: d,
282 - }))
309 + .send(AppEvent::DataLoaded(DataPayload::ItemUpdated { detail: d }))
283 310 .await;
284 311 }
285 312 Err(e) => {
@@ -306,9 +333,7 @@ pub(crate) async fn handle_item_input(
306 333 match api.unpublish_item(&user_id, &item_id).await {
307 334 Ok(d) => {
308 335 let _ = tx
309 - .send(AppEvent::DataLoaded(DataPayload::ItemUpdated {
310 - detail: d,
311 - }))
336 + .send(AppEvent::DataLoaded(DataPayload::ItemUpdated { detail: d }))
312 337 .await;
313 338 }
314 339 Err(e) => {
@@ -365,13 +390,13 @@ pub(crate) async fn handle_item_input(
365 390 }
366 391 }
367 392 }
368 - KeyCode::Char('t') | KeyCode::Char('T')
369 - if app.item_detail.is_some() => {
370 - app.tag_searching = true;
371 - app.edit_buffer.clear();
372 - app.tag_search_results.clear();
373 - app.item_status = Some("Tag: type to search, Enter to add first result, Esc to cancel".to_string());
374 - }
393 + KeyCode::Char('t') | KeyCode::Char('T') if app.item_detail.is_some() => {
394 + app.tag_searching = true;
395 + app.edit_buffer.clear();
396 + app.tag_search_results.clear();
397 + app.item_status =
398 + Some("Tag: type to search, Enter to add first result, Esc to cancel".to_string());
399 + }
375 400 KeyCode::Char('l') | KeyCode::Char('L') => {
376 401 // Open license keys screen
377 402 if let Screen::Item(project_idx, item_id) = &*screen {
@@ -10,7 +10,8 @@ pub(crate) async fn handle_keys_input(
10 10 tx: &mpsc::Sender<AppEvent>,
11 11 ) {
12 12 // Cancel pending confirmation on any key other than the confirmation key
13 - if app.confirm_action.is_some() && !matches!(key.code, KeyCode::Char('x') | KeyCode::Char('X')) {
13 + if app.confirm_action.is_some() && !matches!(key.code, KeyCode::Char('x') | KeyCode::Char('X'))
14 + {
14 15 app.confirm_action = None;
15 16 app.keys_status = None;
16 17 }
@@ -65,51 +66,51 @@ pub(crate) async fn handle_keys_input(
65 66 });
66 67 }
67 68 }
68 - KeyCode::Char('x') | KeyCode::Char('X')
69 - if !app.license_keys.is_empty() => {
70 - let idx = app.selected_index;
71 - if matches!(app.confirm_action, Some(ConfirmAction::RevokeLicenseKey { key_idx }) if key_idx == idx) {
72 - // Confirmed — execute revoke
73 - app.confirm_action = None;
74 - let key_id = app.license_keys[idx].id.clone();
75 - let key_code = app.license_keys[idx].key_code.clone();
69 + KeyCode::Char('x') | KeyCode::Char('X') if !app.license_keys.is_empty() => {
70 + let idx = app.selected_index;
71 + if matches!(app.confirm_action, Some(ConfirmAction::RevokeLicenseKey { key_idx }) if key_idx == idx)
72 + {
73 + // Confirmed — execute revoke
74 + app.confirm_action = None;
75 + let key_id = app.license_keys[idx].id.clone();
76 + let key_code = app.license_keys[idx].key_code.clone();
77 + let user_id = app.user.user_id.clone();
78 + tracing::info!(
79 + user_id = %user_id,
80 + key_id = %key_id,
81 + key_code = %key_code,
82 + "revoke license key confirmed"
83 + );
84 + if let Screen::Keys(_, item_id) = &*screen {
85 + let item_id = item_id.clone();
86 + let api = api.clone();
76 87 let user_id = app.user.user_id.clone();
77 - tracing::info!(
78 - user_id = %user_id,
79 - key_id = %key_id,
80 - key_code = %key_code,
81 - "revoke license key confirmed"
82 - );
83 - if let Screen::Keys(_, item_id) = &*screen {
84 - let item_id = item_id.clone();
85 - let api = api.clone();
86 - let user_id = app.user.user_id.clone();
87 - let tx = tx.clone();
88 - app.keys_status = Some("Revoking...".to_string());
89 - tokio::spawn(async move {
90 - match api.revoke_license_key(&user_id, &key_id).await {
91 - Ok(()) => {
92 - load_license_keys(&api, &user_id, &item_id, &tx).await;
93 - }
94 - Err(e) => {
95 - let _ = tx
96 - .send(AppEvent::DataLoaded(DataPayload::GenericError {
97 - error: e.to_string(),
98 - }))
99 - .await;
100 - }
88 + let tx = tx.clone();
89 + app.keys_status = Some("Revoking...".to_string());
90 + tokio::spawn(async move {
91 + match api.revoke_license_key(&user_id, &key_id).await {
92 + Ok(()) => {
93 + load_license_keys(&api, &user_id, &item_id, &tx).await;
101 94 }
102 - });
103 - }
104 - } else {
105 - // First press — ask for confirmation
106 - app.confirm_action = Some(ConfirmAction::RevokeLicenseKey { key_idx: idx });
107 - app.keys_status = Some(format!(
108 - "Revoke '{}'? Press x again to confirm",
109 - app.license_keys[idx].key_code
110 - ));
95 + Err(e) => {
96 + let _ = tx
97 + .send(AppEvent::DataLoaded(DataPayload::GenericError {
98 + error: e.to_string(),
99 + }))
100 + .await;
101 + }
102 + }
103 + });
111 104 }
105 + } else {
106 + // First press — ask for confirmation
107 + app.confirm_action = Some(ConfirmAction::RevokeLicenseKey { key_idx: idx });
108 + app.keys_status = Some(format!(
109 + "Revoke '{}'? Press x again to confirm",
110 + app.license_keys[idx].key_code
111 + ));
112 112 }
113 + }
113 114 KeyCode::Char('r') | KeyCode::Char('R') => {
114 115 if let Screen::Keys(_, item_id) = &*screen {
115 116 app.loading = true;
@@ -9,30 +9,29 @@ use crate::api::MnwApiClient;
9 9
10 10 use super::loading::*;
11 11 use super::{
12 - App, AppEvent, BlogCreateStep, ConfirmAction, DataPayload, EditField, PromoCreateStep,
13 - Screen,
12 + App, AppEvent, BlogCreateStep, ConfirmAction, DataPayload, EditField, PromoCreateStep, Screen,
14 13 };
15 14
15 + mod analytics;
16 + mod blog;
17 + mod collections;
16 18 mod home;
17 - mod project;
18 - mod upload;
19 19 mod item;
20 - mod blog;
21 - mod promo;
22 20 mod keys;
23 - mod analytics;
21 + mod project;
22 + mod promo;
24 23 mod settings;
25 - mod collections;
26 24 mod tiers;
25 + mod upload;
27 26
27 + pub(crate) use analytics::*;
28 + pub(crate) use blog::*;
29 + pub(crate) use collections::*;
28 30 pub(crate) use home::*;
29 - pub(crate) use project::*;
30 - pub(crate) use upload::*;
31 31 pub(crate) use item::*;
32 - pub(crate) use blog::*;
33 - pub(crate) use promo::*;
34 32 pub(crate) use keys::*;
35 - pub(crate) use analytics::*;
33 + pub(crate) use project::*;
34 + pub(crate) use promo::*;
36 35 pub(crate) use settings::*;
37 - pub(crate) use collections::*;
38 36 pub(crate) use tiers::*;
37 + pub(crate) use upload::*;
M pom/src/api.rs +203 -121
M pom/src/cli/mod.rs +29 -28
M pom/src/config.rs +120 -26
M pom/src/db/cors.rs +12 -13
M pom/src/db/mod.rs +22 -19
M pom/src/display.rs +450 -56
M pom/src/main.rs +22 -10
M pom/src/peer.rs +35 -14
M server/build.rs +7 -2
M server/deny.toml +11 -16
M server/src/auth.rs +103 -39
M server/src/csrf.rs +64 -42
M server/src/lib.rs +105 -68
M server/src/main.rs +138 -93
M server/src/wordlist.rs +189 -2048
M wam/src/api.rs +17 -10
M wam/src/cli.rs +4 -1
M wam/src/db.rs +187 -80
M wam/src/main.rs +64 -28
M wam/src/tui.rs +79 -41