Skip to main content

max / makenotwork

34.1 KB · 949 lines History Blame Raw
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 std::fmt::Write as _;
8
9 use crate::api::{MnwApiClient, UserInfo};
10 use crate::format;
11 use crate::staging;
12
13 /// Sanitize an API error for display to SSH clients.
14 ///
15 /// Strips raw response bodies (HTML error pages, stack traces) that may leak
16 /// from anyhow error chains. Keeps the high-level context + HTTP status code
17 /// but drops everything after ` — ` (the body separator our API client uses).
18 pub(crate) fn sanitize_api_error(e: &anyhow::Error) -> String {
19 let msg = e.to_string();
20 // Our API client formats errors as "context: HTTP 500 — <body>".
21 // Keep the part before the body separator.
22 if let Some(idx) = msg.find(" \u{2014} ") {
23 return msg[..idx].to_string();
24 }
25 // For connection/timeout errors, return a generic message.
26 if msg.contains("connect") || msg.contains("timed out") || msg.contains("dns") {
27 return "Service temporarily unavailable".to_string();
28 }
29 // Fallback: return the first line only (avoid multi-line leaks).
30 msg.lines().next().unwrap_or("Unknown error").to_string()
31 }
32
33 /// Execute a non-interactive command and return the output bytes.
34 pub(crate) async fn execute(command_line: &str, user: &UserInfo, api: &MnwApiClient) -> Vec<u8> {
35 let parts: Vec<&str> = command_line.split_whitespace().collect();
36 if parts.is_empty() {
37 return help_text();
38 }
39
40 let json = parts.contains(&"--json");
41 let parts: Vec<&str> = parts.into_iter().filter(|p| *p != "--json").collect();
42
43 match parts[0] {
44 "project" => match parts.get(1).copied() {
45 Some("create") => {
46 let title = extract_flag(&parts, &["--title", "-t"]).unwrap_or_default();
47 let ptype = extract_flag(&parts, &["--type"]).unwrap_or_else(|| "digital".to_string());
48 let desc = extract_flag(&parts, &["--description", "--desc", "-d"]);
49 cmd_project_create(user, api, &title, &ptype, desc.as_deref()).await
50 }
51 _ => b"Usage: project create --title \"Name\" [--type audio|digital|video|mixed|subscription] [--description \"...\"]\r\n".to_vec(),
52 },
53 "upload" => {
54 b"Pipe uploads use stdin. Example:\r\n cat file.wav | ssh cli.makenot.work upload --filename track.wav --project my-project\r\n".to_vec()
55 }
56 "projects" => cmd_projects(user, api, json).await,
57 "analytics" => {
58 let range = parts
59 .iter()
60 .find_map(|p| p.strip_prefix("--range="))
61 .unwrap_or("30d");
62 cmd_analytics(user, api, range, json).await
63 }
64 "transactions" => cmd_transactions(user, api, json).await,
65 "export" if parts.get(1) == Some(&"sales") => cmd_export_sales(user, api).await,
66 "promo" => match parts.get(1).copied() {
67 Some("list") => cmd_promo_list(user, api, json).await,
68 Some("create") => {
69 let code = parts.get(2).unwrap_or(&"");
70 let pct = parts.get(3).unwrap_or(&"0");
71 cmd_promo_create(user, api, code, pct).await
72 }
73 _ => b"Usage: promo list | promo create CODE DISCOUNT_PCT\r\n".to_vec(),
74 },
75 "blog" => match parts.get(1).copied() {
76 Some("list") => {
77 let slug = parts.get(2).unwrap_or(&"");
78 cmd_blog_list(user, api, slug, json).await
79 }
80 _ => b"Usage: blog list <project-slug>\r\n".to_vec(),
81 },
82 "broadcast" => {
83 let subject = extract_flag(&parts, &["--subject", "-s"]).unwrap_or_default();
84 let body = extract_flag(&parts, &["--body", "-b"]).unwrap_or_default();
85 cmd_broadcast(user, api, &subject, &body).await
86 }
87 "collections" => cmd_collections(user, api, json).await,
88 "domain" => match parts.get(1).copied() {
89 Some("add") => {
90 let domain = parts.get(2).unwrap_or(&"");
91 cmd_domain_add(user, api, domain).await
92 }
93 Some("verify") => cmd_domain_verify(user, api).await,
94 Some("remove") => cmd_domain_remove(user, api).await,
95 _ => cmd_domain_show(user, api).await,
96 },
97 "repo" => match parts.get(1).copied() {
98 Some("list") => cmd_repo_list(user, api, json).await,
99 Some("info") => cmd_repo_info(user, api, parts.get(2).unwrap_or(&""), json).await,
100 Some("set-visibility") => {
101 cmd_repo_set_visibility(
102 user,
103 api,
104 parts.get(2).unwrap_or(&""),
105 parts.get(3).unwrap_or(&""),
106 )
107 .await
108 }
109 // The description is the rest of the line, so it is rejoined rather
110 // than read from one slot: a description is prose and almost always
111 // has a space in it.
112 Some("set-description") => {
113 let desc = parts.get(3..).map(|r| r.join(" ")).unwrap_or_default();
114 cmd_repo_set_description(
115 user,
116 api,
117 parts.get(2).unwrap_or(&""),
118 unquote(&desc),
119 )
120 .await
121 }
122 // --confirm is required and deliberately not inferable. This is the
123 // only verb here that destroys anything.
124 Some("delete") => {
125 if parts.contains(&"--confirm") {
126 cmd_repo_delete(user, api, parts.get(2).unwrap_or(&"")).await
127 } else {
128 b"repo delete requires --confirm\r\nUsage: repo delete <name> --confirm\r\n"
129 .to_vec()
130 }
131 }
132 _ => b"Usage: repo list | info NAME | set-visibility NAME public|unlisted|private | set-description NAME TEXT | delete NAME --confirm\r\n".to_vec(),
133 },
134 "key" => match parts.get(1).copied() {
135 Some("list") => cmd_key_list(user, api, json).await,
136 Some("rm") => cmd_key_remove(user, api, parts.get(2).unwrap_or(&"")).await,
137 _ => b"Usage: key list | key rm FINGERPRINT\r\n".to_vec(),
138 },
139 "help" | "--help" | "-h" => help_text(),
140 other => format!("Unknown command: {other}\r\nRun without arguments for usage help.\r\n")
141 .into_bytes(),
142 }
143 }
144
145 async fn cmd_project_create(
146 user: &UserInfo,
147 api: &MnwApiClient,
148 title: &str,
149 project_type: &str,
150 description: Option<&str>,
151 ) -> Vec<u8> {
152 if title.is_empty() {
153 return b"Usage: project create --title \"Name\" [--type audio|digital|video|mixed|subscription] [--description \"...\"]\r\n".to_vec();
154 }
155 if !user.can_create_projects {
156 return b"Error: your account cannot create projects. Upgrade your tier at makenot.work/pricing\r\n".to_vec();
157 }
158 match api
159 .create_project(&user.user_id, title, project_type, description)
160 .await
161 {
162 Ok(p) => format!(
163 "Created project: {} (slug: {}, type: {})\r\n",
164 p.title, p.slug, p.project_type
165 )
166 .into_bytes(),
167 Err(e) => format!("Error: {}\r\n", sanitize_api_error(&e)).into_bytes(),
168 }
169 }
170
171 async fn cmd_projects(user: &UserInfo, api: &MnwApiClient, json: bool) -> Vec<u8> {
172 match api.get_projects(&user.user_id).await {
173 Ok(projects) => {
174 if json {
175 return serde_json::to_vec_pretty(&projects).unwrap_or_default();
176 }
177 if projects.is_empty() {
178 return b"No projects.\r\n".to_vec();
179 }
180 let mut out = format!(
181 "{:<30} {:<12} {:<8} {:<6} {:<15}\r\n",
182 "Title", "Type", "Status", "Items", "Revenue"
183 );
184 out.push_str(&"-".repeat(75));
185 out.push_str("\r\n");
186 for p in &projects {
187 let status = if p.is_public { "public" } else { "draft" };
188 let revenue = p.revenue().display_compact(user.settlement_currency);
189 write!(
190 out,
191 "{:<30} {:<12} {:<8} {:<6} {:<15}\r\n",
192 truncate(&p.title, 29),
193 p.project_type,
194 status,
195 p.item_count,
196 revenue,
197 )
198 .unwrap();
199 }
200 out.into_bytes()
201 }
202 Err(e) => format!("Error: {}\r\n", sanitize_api_error(&e)).into_bytes(),
203 }
204 }
205
206 async fn cmd_analytics(user: &UserInfo, api: &MnwApiClient, range: &str, json: bool) -> Vec<u8> {
207 match api.get_analytics(&user.user_id, range).await {
208 Ok(data) => {
209 if json {
210 return serde_json::to_vec_pretty(&data).unwrap_or_default();
211 }
212
213 let mut out = String::new();
214 write!(out, "Analytics ({range})\r\n\r\n").unwrap();
215
216 let rev = format::format_cents(data.current_revenue_cents, user.settlement_currency);
217 let prev_rev =
218 format::format_cents(data.previous_revenue_cents, user.settlement_currency);
219 write!(out, "Revenue: {rev} (prev: {prev_rev})\r\n").unwrap();
220 write!(
221 out,
222 "Sales: {} (prev: {})\r\n",
223 data.current_sales, data.previous_sales
224 )
225 .unwrap();
226 write!(
227 out,
228 "Followers: {} (prev: {})\r\n",
229 data.current_followers, data.previous_followers
230 )
231 .unwrap();
232
233 if !data.top_projects.is_empty() {
234 out.push_str("\r\nTop Projects:\r\n");
235 for p in &data.top_projects {
236 write!(
237 out,
238 " {:<30} {}\r\n",
239 truncate(&p.title, 29),
240 p.revenue().display(user.settlement_currency)
241 )
242 .unwrap();
243 }
244 }
245
246 out.into_bytes()
247 }
248 Err(e) => format!("Error: {}\r\n", sanitize_api_error(&e)).into_bytes(),
249 }
250 }
251
252 async fn cmd_transactions(user: &UserInfo, api: &MnwApiClient, json: bool) -> Vec<u8> {
253 match api.get_transactions(&user.user_id).await {
254 Ok(txs) => {
255 if json {
256 return serde_json::to_vec_pretty(&txs).unwrap_or_default();
257 }
258 if txs.is_empty() {
259 return b"No transactions.\r\n".to_vec();
260 }
261 let mut out = format!(
262 "{:<30} {:<10} {:<12} {:<12}\r\n",
263 "Item", "Amount", "Status", "Date"
264 );
265 out.push_str(&"-".repeat(66));
266 out.push_str("\r\n");
267 for tx in &txs {
268 let title = tx.item_title.as_deref().unwrap_or("--");
269 let amount =
270 format::format_cents(i64::from(tx.amount_cents), user.settlement_currency);
271 let date = tx.created_at.get(..10).unwrap_or(&tx.created_at);
272 write!(
273 out,
274 "{:<30} {:<10} {:<12} {:<12}\r\n",
275 truncate(title, 29),
276 amount,
277 tx.status,
278 date,
279 )
280 .unwrap();
281 }
282 out.into_bytes()
283 }
284 Err(e) => format!("Error: {}\r\n", sanitize_api_error(&e)).into_bytes(),
285 }
286 }
287
288 async fn cmd_export_sales(user: &UserInfo, api: &MnwApiClient) -> Vec<u8> {
289 match api.export_sales_csv(&user.user_id).await {
290 Ok(result) => result.csv.into_bytes(),
291 Err(e) => format!("Error: {}\r\n", sanitize_api_error(&e)).into_bytes(),
292 }
293 }
294
295 async fn cmd_promo_list(user: &UserInfo, api: &MnwApiClient, json: bool) -> Vec<u8> {
296 match api.list_promo_codes(&user.user_id).await {
297 Ok(codes) => {
298 if json {
299 return serde_json::to_vec_pretty(&codes).unwrap_or_default();
300 }
301 if codes.is_empty() {
302 return b"No promo codes.\r\n".to_vec();
303 }
304 let mut out = format!(
305 "{:<20} {:<16} {:<20} {:<10}\r\n",
306 "Code", "Discount", "Scope", "Uses"
307 );
308 out.push_str(&"-".repeat(68));
309 out.push_str("\r\n");
310 for c in &codes {
311 let discount = match (c.discount_type.as_deref(), c.discount_value) {
312 (Some("percentage"), Some(v)) => format!("{v}% off"),
313 (Some("fixed"), Some(v)) => format!(
314 "{} off",
315 format::format_cents(i64::from(v), user.settlement_currency)
316 ),
317 _ => "Free".to_string(),
318 };
319 let scope = c
320 .item_title
321 .as_deref()
322 .or(c.project_title.as_deref())
323 .unwrap_or("All items");
324 let uses = match c.max_uses {
325 Some(max) => format!("{}/{}", c.use_count, max),
326 None => c.use_count.to_string(),
327 };
328 write!(
329 out,
330 "{:<20} {:<16} {:<20} {:<10}\r\n",
331 truncate(&c.code, 19),
332 discount,
333 truncate(scope, 19),
334 uses,
335 )
336 .unwrap();
337 }
338 out.into_bytes()
339 }
340 Err(e) => format!("Error: {}\r\n", sanitize_api_error(&e)).into_bytes(),
341 }
342 }
343
344 async fn cmd_promo_create(user: &UserInfo, api: &MnwApiClient, code: &str, pct: &str) -> Vec<u8> {
345 if code.is_empty() {
346 return b"Usage: promo create CODE DISCOUNT_PCT\r\n".to_vec();
347 }
348 let discount: i32 = pct.parse().unwrap_or(0);
349 match api
350 .create_promo_code(&user.user_id, code, "percentage", discount, None, None)
351 .await
352 {
353 Ok(_) => format!("Created promo code: {code} ({discount}% off)\r\n").into_bytes(),
354 Err(e) => format!("Error: {}\r\n", sanitize_api_error(&e)).into_bytes(),
355 }
356 }
357
358 async fn cmd_blog_list(user: &UserInfo, api: &MnwApiClient, slug: &str, json: bool) -> Vec<u8> {
359 if slug.is_empty() {
360 return b"Usage: blog list <project-slug>\r\n".to_vec();
361 }
362
363 // Find the project by slug
364 let projects = match api.get_projects(&user.user_id).await {
365 Ok(p) => p,
366 Err(e) => return format!("Error: {}\r\n", sanitize_api_error(&e)).into_bytes(),
367 };
368
369 let Some(project) = projects.iter().find(|p| p.slug == slug) else {
370 return format!("Project not found: {slug}\r\n").into_bytes();
371 };
372
373 match api.list_blog_posts(&user.user_id, &project.id).await {
374 Ok(posts) => {
375 if json {
376 return serde_json::to_vec_pretty(&posts).unwrap_or_default();
377 }
378 if posts.is_empty() {
379 return b"No blog posts.\r\n".to_vec();
380 }
381 let mut out = format!(
382 "{:<30} {:<20} {:<10} {:<12}\r\n",
383 "Title", "Slug", "Status", "Created"
384 );
385 out.push_str(&"-".repeat(74));
386 out.push_str("\r\n");
387 for p in &posts {
388 let status = if p.is_published { "published" } else { "draft" };
389 let date = p.created_at.get(..10).unwrap_or(&p.created_at);
390 write!(
391 out,
392 "{:<30} {:<20} {:<10} {:<12}\r\n",
393 truncate(&p.title, 29),
394 truncate(&p.slug, 19),
395 status,
396 date,
397 )
398 .unwrap();
399 }
400 out.into_bytes()
401 }
402 Err(e) => format!("Error: {}\r\n", sanitize_api_error(&e)).into_bytes(),
403 }
404 }
405
406 async fn cmd_broadcast(user: &UserInfo, api: &MnwApiClient, subject: &str, body: &str) -> Vec<u8> {
407 if subject.is_empty() || body.is_empty() {
408 return b"Usage: broadcast --subject \"Subject line\" --body \"Body text\"\r\n\
409 \x20 Aliases: -s, -b\r\n\
410 \x20 Sends an email to all your followers (1/24h limit).\r\n"
411 .to_vec();
412 }
413 match api.send_broadcast(&user.user_id, subject, body).await {
414 Ok(result) => format!(
415 "Broadcast sent to {} followers.\r\n",
416 result.recipient_count
417 )
418 .into_bytes(),
419 Err(e) => format!("Error: {}\r\n", sanitize_api_error(&e)).into_bytes(),
420 }
421 }
422
423 async fn cmd_collections(user: &UserInfo, api: &MnwApiClient, json: bool) -> Vec<u8> {
424 match api.list_collections(&user.user_id).await {
425 Ok(collections) => {
426 if json {
427 return serde_json::to_vec_pretty(&collections).unwrap_or_default();
428 }
429 if collections.is_empty() {
430 return b"No collections.\r\n".to_vec();
431 }
432 let mut out = format!(
433 "{:<25} {:<25} {:<8} {:<6}\r\n",
434 "Title", "Slug", "Status", "Items"
435 );
436 out.push_str(&"-".repeat(66));
437 out.push_str("\r\n");
438 for c in &collections {
439 let status = if c.is_public { "public" } else { "draft" };
440 write!(
441 out,
442 "{:<25} {:<25} {:<8} {:<6}\r\n",
443 truncate(&c.title, 24),
444 truncate(&c.slug, 24),
445 status,
446 c.item_count,
447 )
448 .unwrap();
449 }
450 out.into_bytes()
451 }
452 Err(e) => format!("Error: {}\r\n", sanitize_api_error(&e)).into_bytes(),
453 }
454 }
455
456 async fn cmd_domain_show(user: &UserInfo, api: &MnwApiClient) -> Vec<u8> {
457 match api.get_domain(&user.user_id).await {
458 Ok(Some(d)) => {
459 let status = if d.verified { "verified" } else { "pending" };
460 let mut out = format!("Domain: {} ({})\r\n", d.domain, status);
461 if !d.verified
462 && let Some(ref instr) = d.instructions
463 {
464 write!(out, "{instr}\r\n").unwrap();
465 }
466 out.into_bytes()
467 }
468 Ok(None) => b"No custom domain configured.\r\nUsage: domain add <domain>\r\n".to_vec(),
469 Err(e) => format!("Error: {}\r\n", sanitize_api_error(&e)).into_bytes(),
470 }
471 }
472
473 async fn cmd_domain_add(user: &UserInfo, api: &MnwApiClient, domain: &str) -> Vec<u8> {
474 if domain.is_empty() {
475 return b"Usage: domain add <domain>\r\n".to_vec();
476 }
477 match api.add_domain(&user.user_id, domain).await {
478 Ok(d) => {
479 let mut out = format!("Domain added: {}\r\n", d.domain);
480 if let Some(ref instr) = d.instructions {
481 write!(out, "{instr}\r\n").unwrap();
482 }
483 out.push_str("Run `domain verify` after adding the DNS record.\r\n");
484 out.into_bytes()
485 }
486 Err(e) => format!("Error: {}\r\n", sanitize_api_error(&e)).into_bytes(),
487 }
488 }
489
490 async fn cmd_domain_verify(user: &UserInfo, api: &MnwApiClient) -> Vec<u8> {
491 match api.verify_domain(&user.user_id).await {
492 Ok(result) => format!("{}\r\n", result.message).into_bytes(),
493 Err(e) => format!("Error: {}\r\n", sanitize_api_error(&e)).into_bytes(),
494 }
495 }
496
497 async fn cmd_domain_remove(user: &UserInfo, api: &MnwApiClient) -> Vec<u8> {
498 match api.remove_domain(&user.user_id).await {
499 Ok(()) => b"Domain removed.\r\n".to_vec(),
500 Err(e) => format!("Error: {}\r\n", sanitize_api_error(&e)).into_bytes(),
501 }
502 }
503
504 /// Execute a pipe-mode file upload (called from handler after stdin EOF).
505 pub(crate) async fn execute_pipe_upload(
506 api: &MnwApiClient,
507 upload: crate::ssh::handler::PipeUpload,
508 ) -> anyhow::Result<String> {
509 let user = &upload.user;
510 if upload.data.is_empty() {
511 anyhow::bail!("no data received on stdin");
512 }
513
514 let ext = upload
515 .filename
516 .rsplit('.')
517 .next()
518 .unwrap_or("")
519 .to_lowercase();
520 let classification = staging::classify_extension(&ext)
521 .ok_or_else(|| anyhow::anyhow!(
522 "unsupported file type: .{ext}\r\nSupported: mp3, wav, flac, ogg, m4a, aac, zip, dmg, exe, appimage, deb, clap, vst3"
523 ))?;
524
525 // Find project by slug
526 let projects = api.get_projects(&user.user_id).await?;
527 let project = projects
528 .iter()
529 .find(|p| p.slug == upload.project_slug)
530 .ok_or_else(|| anyhow::anyhow!("project not found: {}", upload.project_slug))?;
531
532 // Create item
533 let item = api
534 .create_item(
535 &user.user_id,
536 &project.id,
537 &upload.title,
538 classification.item_type,
539 upload.price_cents,
540 )
541 .await?;
542
543 // Presign upload
544 let presign = api
545 .presign_upload(
546 &user.user_id,
547 &item.item_id,
548 classification.file_type,
549 &upload.filename,
550 classification.content_type,
551 )
552 .await?;
553
554 // Upload data directly to S3
555 let resp = crate::tls::builder()
556 .build()?
557 .put(&presign.upload_url)
558 .header("content-type", classification.content_type)
559 .body(upload.data)
560 .send()
561 .await?;
562
563 if !resp.status().is_success() {
564 anyhow::bail!("S3 upload failed: HTTP {}", resp.status());
565 }
566
567 // Confirm
568 api.confirm_upload(
569 &user.user_id,
570 &item.item_id,
571 classification.file_type,
572 &presign.s3_key,
573 )
574 .await?;
575
576 // Publish
577 api.publish_item(&user.user_id, &item.item_id).await?;
578
579 Ok(format!(
580 "Uploaded and published: {} ({}, {})\r\n",
581 upload.title,
582 staging::format_bytes(resp.content_length().unwrap_or(0)),
583 classification.item_type,
584 ))
585 }
586
587 // ── Git repositories and SSH keys ──
588 //
589 // These verbs existed in the server's git_ssh.rs, reachable only through the
590 // sshd `command=` path that the live SSH front door does not use. They are
591 // here now because this is the door people actually knock on.
592
593 async fn cmd_repo_list(user: &UserInfo, api: &MnwApiClient, json: bool) -> Vec<u8> {
594 match api.repo_list(&user.user_id).await {
595 Ok(repos) => {
596 if json {
597 return serde_json::to_vec_pretty(&repos).unwrap_or_default();
598 }
599 if repos.is_empty() {
600 return b"No repositories.\r\n".to_vec();
601 }
602 let mut out = format!("{:<30} {:<10} {}\r\n", "Name", "Visibility", "Description");
603 out.push_str(&"-".repeat(70));
604 out.push_str("\r\n");
605 for r in &repos {
606 let desc = if r.description.is_empty() {
607 "-"
608 } else {
609 truncate(&r.description, 28)
610 };
611 write!(
612 out,
613 "{:<30} {:<10} {}\r\n",
614 truncate(&r.name, 29),
615 r.visibility,
616 desc
617 )
618 .unwrap();
619 }
620 write!(out, "\r\n{} repository(ies).\r\n", repos.len()).unwrap();
621 out.into_bytes()
622 }
623 Err(e) => format!("Error: {}\r\n", sanitize_api_error(&e)).into_bytes(),
624 }
625 }
626
627 async fn cmd_repo_info(user: &UserInfo, api: &MnwApiClient, name: &str, json: bool) -> Vec<u8> {
628 if name.is_empty() {
629 return b"Usage: repo info <name>\r\n".to_vec();
630 }
631 match api.repo_info(&user.user_id, name).await {
632 Ok(r) => {
633 if json {
634 return serde_json::to_vec_pretty(&r).unwrap_or_default();
635 }
636 let desc = if r.description.is_empty() {
637 "-"
638 } else {
639 &r.description
640 };
641 format!(
642 "Name: {}\r\nVisibility: {}\r\nDescription: {}\r\nCreated: {}\r\nIssues: {} open, {} closed\r\n",
643 r.name, r.visibility, desc, r.created_at, r.open_issues, r.closed_issues,
644 )
645 .into_bytes()
646 }
647 Err(e) => format!("Error: {}\r\n", sanitize_api_error(&e)).into_bytes(),
648 }
649 }
650
651 async fn cmd_repo_set_visibility(
652 user: &UserInfo,
653 api: &MnwApiClient,
654 name: &str,
655 visibility: &str,
656 ) -> Vec<u8> {
657 if name.is_empty() || visibility.is_empty() {
658 return b"Usage: repo set-visibility <name> <public|unlisted|private>\r\n".to_vec();
659 }
660 if !matches!(visibility, "public" | "unlisted" | "private") {
661 return b"Visibility must be public, unlisted, or private.\r\n".to_vec();
662 }
663 match api
664 .repo_set_visibility(&user.user_id, name, visibility)
665 .await
666 {
667 Ok(()) => format!("Set visibility of '{name}' to '{visibility}'.\r\n").into_bytes(),
668 Err(e) => format!("Error: {}\r\n", sanitize_api_error(&e)).into_bytes(),
669 }
670 }
671
672 async fn cmd_repo_set_description(
673 user: &UserInfo,
674 api: &MnwApiClient,
675 name: &str,
676 description: &str,
677 ) -> Vec<u8> {
678 if name.is_empty() {
679 return b"Usage: repo set-description <name> <description>\r\n".to_vec();
680 }
681 match api
682 .repo_set_description(&user.user_id, name, description)
683 .await
684 {
685 Ok(()) => format!("Updated description of '{name}'.\r\n").into_bytes(),
686 Err(e) => format!("Error: {}\r\n", sanitize_api_error(&e)).into_bytes(),
687 }
688 }
689
690 async fn cmd_repo_delete(user: &UserInfo, api: &MnwApiClient, name: &str) -> Vec<u8> {
691 if name.is_empty() {
692 return b"Usage: repo delete <name> --confirm\r\n".to_vec();
693 }
694 match api.repo_delete(&user.user_id, name).await {
695 Ok(()) => format!("Deleted repository '{name}'.\r\n").into_bytes(),
696 Err(e) => format!("Error: {}\r\n", sanitize_api_error(&e)).into_bytes(),
697 }
698 }
699
700 async fn cmd_key_list(user: &UserInfo, api: &MnwApiClient, json: bool) -> Vec<u8> {
701 match api.key_list(&user.user_id).await {
702 Ok(keys) => {
703 if json {
704 return serde_json::to_vec_pretty(&keys).unwrap_or_default();
705 }
706 if keys.is_empty() {
707 return b"No SSH keys.\r\n".to_vec();
708 }
709 let mut out = format!("{:<50} {:<20} {}\r\n", "Fingerprint", "Label", "Added");
710 out.push_str(&"-".repeat(80));
711 out.push_str("\r\n");
712 for k in &keys {
713 let label = if k.label.is_empty() {
714 "-"
715 } else {
716 truncate(&k.label, 20)
717 };
718 // The shared endpoint returns RFC3339; the table wants a date.
719 let added = k.created_at.get(..10).unwrap_or(&k.created_at);
720 write!(out, "{:<50} {:<20} {}\r\n", k.fingerprint, label, added).unwrap();
721 }
722 write!(out, "\r\n{} key(s).\r\n", keys.len()).unwrap();
723 out.into_bytes()
724 }
725 Err(e) => format!("Error: {}\r\n", sanitize_api_error(&e)).into_bytes(),
726 }
727 }
728
729 async fn cmd_key_remove(user: &UserInfo, api: &MnwApiClient, fingerprint: &str) -> Vec<u8> {
730 if fingerprint.is_empty() {
731 return b"Usage: key rm <fingerprint>\r\n".to_vec();
732 }
733 match api.key_remove(&user.user_id, fingerprint).await {
734 Ok(()) => format!("Removed SSH key '{fingerprint}'.\r\n").into_bytes(),
735 Err(e) => format!("Error: {}\r\n", sanitize_api_error(&e)).into_bytes(),
736 }
737 }
738
739 pub(crate) fn help_text() -> Vec<u8> {
740 b"Usage: ssh cli.makenot.work <command>\r\n\
741 \r\n\
742 Commands:\r\n\
743 \x20 projects List your projects\r\n\
744 \x20 project create [opts] Create a new project\r\n\
745 \x20 analytics [--range=N] Revenue stats (7d/30d/90d/all)\r\n\
746 \x20 transactions Recent transactions\r\n\
747 \x20 export sales Export sales as CSV\r\n\
748 \x20 promo list List promo codes\r\n\
749 \x20 promo create CODE PCT Create a promo code\r\n\
750 \x20 blog list SLUG List blog posts for project\r\n\
751 \x20 broadcast -s SUBJ -b BODY Email followers (1/24h limit)\r\n\
752 \x20 collections List your collections\r\n\
753 \x20 domain Show custom domain\r\n\
754 \x20 domain add DOMAIN Add a custom domain\r\n\
755 \x20 domain verify Verify DNS record\r\n\
756 \x20 domain remove Remove custom domain\r\n\
757 \x20 upload [args] Pipe upload (see below)\r\n\
758 \x20 repo list List your git repositories\r\n\
759 \x20 repo info NAME Show one repository\r\n\
760 \x20 repo set-visibility NAME public|unlisted|private\r\n\
761 \x20 repo set-description NAME TEXT\r\n\
762 \x20 repo delete NAME --confirm Delete a repository\r\n\
763 \x20 key list List your SSH keys\r\n\
764 \x20 key rm FINGERPRINT Remove an SSH key\r\n\
765 \r\n\
766 Add --json to any command for machine-readable output.\r\n\
767 \r\n\
768 Pipe uploads:\r\n\
769 \x20 cat file.wav | ssh cli.makenot.work upload --filename track.wav --project my-slug\r\n\
770 \x20 Options: --filename/-f NAME --project/-p SLUG [--title/-t TITLE] [--price CENTS]\r\n\
771 \r\n\
772 File uploads (SFTP):\r\n\
773 \x20 scp file.wav cli.makenot.work:upload/\r\n\
774 \x20 Then publish via the interactive TUI (ssh cli.makenot.work)\r\n\
775 \r\n\
776 Git hosting:\r\n\
777 \x20 git remote add mnw cli.makenot.work:username/repo-name\r\n\
778 \x20 git push mnw main\r\n"
779 .to_vec()
780 }
781
782 /// Extract a flag value from command parts. Supports both `--flag value` and `-f value`.
783 /// Handles quoted values that were split by whitespace by rejoining until the closing quote.
784 fn extract_flag(parts: &[&str], flags: &[&str]) -> Option<String> {
785 for (i, part) in parts.iter().enumerate() {
786 if flags.contains(part)
787 && let Some(&next) = parts.get(i + 1)
788 {
789 // If the value starts with a quote, collect until closing quote
790 if next.starts_with('"') || next.starts_with('\'') {
791 let quote = next.as_bytes()[0] as char;
792 let stripped = &next[1..];
793 if stripped.ends_with(quote) {
794 return Some(stripped[..stripped.len() - 1].to_string());
795 }
796 let mut collected = stripped.to_string();
797 for &subsequent in &parts[i + 2..] {
798 collected.push(' ');
799 if subsequent.ends_with(quote) {
800 collected.push_str(&subsequent[..subsequent.len() - 1]);
801 return Some(collected);
802 }
803 collected.push_str(subsequent);
804 }
805 return Some(collected);
806 }
807 return Some(next.to_string());
808 }
809 }
810 None
811 }
812
813 /// Strip one layer of matching surrounding quotes.
814 ///
815 /// The command line is split on whitespace, so a quoted argument arrives as
816 /// several parts and is rejoined by the caller, which puts the quote characters
817 /// back into the middle of the value: `set-description r "A cool project"`
818 /// would otherwise store the description with the quotes attached. The old
819 /// sshd-side parser tokenized with quote awareness and this is what replaces
820 /// that, at the one place where an argument is prose rather than an identifier.
821 fn unquote(s: &str) -> &str {
822 let bytes = s.as_bytes();
823 if bytes.len() >= 2
824 && (bytes[0] == b'"' || bytes[0] == b'\'')
825 && bytes[bytes.len() - 1] == bytes[0]
826 {
827 &s[1..s.len() - 1]
828 } else {
829 s
830 }
831 }
832
833 fn truncate(s: &str, max_len: usize) -> &str {
834 if s.len() <= max_len {
835 s
836 } else {
837 &s[..s.floor_char_boundary(max_len)]
838 }
839 }
840
841 #[cfg(test)]
842 mod tests {
843 use super::*;
844
845 #[test]
846 fn unquote_strips_matching_double_quotes() {
847 assert_eq!(unquote("\"A cool project\""), "A cool project");
848 }
849
850 #[test]
851 fn unquote_strips_matching_single_quotes() {
852 assert_eq!(unquote("'A cool project'"), "A cool project");
853 }
854
855 #[test]
856 fn unquote_leaves_unquoted_text_alone() {
857 assert_eq!(unquote("A cool project"), "A cool project");
858 }
859
860 #[test]
861 fn unquote_leaves_mismatched_quotes_alone() {
862 // A lone quote is part of the description, not a delimiter.
863 assert_eq!(unquote("\"unterminated"), "\"unterminated");
864 assert_eq!(unquote("it's"), "it's");
865 }
866
867 #[test]
868 fn unquote_handles_the_empty_quoted_string() {
869 assert_eq!(unquote("\"\""), "");
870 }
871
872 #[test]
873 fn truncate_short_string() {
874 assert_eq!(truncate("hello", 10), "hello");
875 }
876
877 #[test]
878 fn truncate_exact_length() {
879 assert_eq!(truncate("hello", 5), "hello");
880 }
881
882 #[test]
883 fn truncate_long_string() {
884 assert_eq!(truncate("hello world", 5), "hello");
885 }
886
887 #[test]
888 fn truncate_multibyte_utf8() {
889 // "café" is 5 bytes (é = 2 bytes), truncating at 4 should not panic
890 let result = truncate("café", 4);
891 assert_eq!(result, "caf");
892 }
893
894 #[test]
895 fn truncate_emoji() {
896 // Each emoji is 4 bytes
897 let result = truncate("🎵🎶🎸", 5);
898 assert_eq!(result, "🎵");
899 }
900
901 #[test]
902 fn extract_flag_simple() {
903 let parts = vec!["broadcast", "--subject", "Hello", "--body", "World"];
904 assert_eq!(
905 extract_flag(&parts, &["--subject", "-s"]),
906 Some("Hello".to_string())
907 );
908 assert_eq!(
909 extract_flag(&parts, &["--body", "-b"]),
910 Some("World".to_string())
911 );
912 }
913
914 #[test]
915 fn extract_flag_short() {
916 let parts = vec!["broadcast", "-s", "Hi", "-b", "There"];
917 assert_eq!(
918 extract_flag(&parts, &["--subject", "-s"]),
919 Some("Hi".to_string())
920 );
921 }
922
923 #[test]
924 fn extract_flag_quoted_multiword() {
925 let parts = vec![
926 "broadcast",
927 "--subject",
928 "\"Hello",
929 "everyone\"",
930 "--body",
931 "\"text\"",
932 ];
933 assert_eq!(
934 extract_flag(&parts, &["--subject", "-s"]),
935 Some("Hello everyone".to_string())
936 );
937 assert_eq!(
938 extract_flag(&parts, &["--body", "-b"]),
939 Some("text".to_string())
940 );
941 }
942
943 #[test]
944 fn extract_flag_missing() {
945 let parts = vec!["broadcast", "--subject", "Hi"];
946 assert_eq!(extract_flag(&parts, &["--body", "-b"]), None);
947 }
948 }
949