Skip to main content

pitchfork_cli/cli/
mod.rs

1use crate::Result;
2use miette::IntoDiagnostic as _;
3use std::ffi::{OsStr, OsString};
4use tokio::io::AsyncWriteExt as _;
5use usage_rs::Cli;
6
7mod activate;
8mod api_schema;
9mod boot;
10mod cd;
11mod clean;
12mod command_effects;
13mod completion;
14mod config;
15mod daemons;
16mod disable;
17mod enable;
18mod interactive;
19pub mod json_output;
20mod list;
21pub mod log_sink;
22pub mod logs;
23mod mcp;
24mod project;
25mod proxy;
26mod restart;
27mod run;
28mod schema;
29mod settings;
30mod sponsors;
31mod start;
32mod status;
33mod stop;
34mod supervisor;
35mod tui;
36mod usage;
37mod wait;
38
39#[derive(Debug, usage_rs::Cli)]
40#[usage(
41    name = "pitchfork",
42    version = env!("CARGO_PKG_VERSION"),
43    about = env!("CARGO_PKG_DESCRIPTION"),
44    completion,
45    unknown_flags = "error",
46    arg_required_else_help,
47    config = crate::settings::Settings
48)]
49struct Cli {
50    #[usage(subcommand)]
51    command: Commands,
52}
53
54#[derive(Debug, usage_rs::Subcommands)]
55#[allow(clippy::large_enum_variant)]
56enum Commands {
57    Activate(activate::Activate),
58    #[usage(hide)]
59    ApiSchema(api_schema::ApiSchema),
60    Boot(boot::Boot),
61    #[usage(hide)]
62    Cd(cd::Cd),
63    #[usage(alias = "c")]
64    Clean(clean::Clean),
65    #[usage(alias = "daemon")]
66    Daemons(daemons::Daemons),
67    Completion(completion::Completion),
68    Config(config::Config),
69    #[usage(alias = "d")]
70    Disable(disable::Disable),
71    #[usage(alias = "e")]
72    Enable(enable::Enable),
73    #[usage(alias = "ls")]
74    List(list::List),
75    #[usage(hide)]
76    LogSink(log_sink::LogSink),
77    #[usage(alias = "l")]
78    Logs(logs::Logs),
79    Mcp(mcp::Mcp),
80    Proxy(proxy::Proxy),
81    Project(project::Project),
82    Restart(restart::Restart),
83    #[usage(alias = "r")]
84    Run(run::Run),
85    #[usage(hide)]
86    Schema(schema::Schema),
87    #[usage(alias = "setting")]
88    Settings(settings::Settings),
89    Sponsors(sponsors::Sponsors),
90    #[usage(alias = "s")]
91    Start(start::Start),
92    #[usage(alias = "stat")]
93    Status(status::Status),
94    #[usage(alias = "kill")]
95    Stop(stop::Stop),
96    #[usage(alias = "sup")]
97    Supervisor(supervisor::Supervisor),
98    Tui(tui::Tui),
99    #[usage(hide)]
100    Usage(usage::Usage),
101    #[usage(alias = "w")]
102    Wait(wait::Wait),
103    #[usage(external_subcommand)]
104    Fallback(Vec<OsString>),
105}
106
107/// Parses tokens captured by the implicit subcommand fallback as a
108/// `pitchfork start` invocation, so usage/help/error output reflects that.
109#[derive(Debug, usage_rs::Cli)]
110#[usage(
111    name = "pitchfork",
112    bin = "pitchfork start",
113    version = env!("CARGO_PKG_VERSION"),
114    long_about = start::LONG_ABOUT,
115    unknown_flags = "error"
116)]
117struct StartFallback {
118    #[usage(flatten)]
119    start: start::Start,
120}
121
122fn fallback_failure(argv: &[&OsStr], error: &usage_rs::Error<'_, '_>) -> (String, i32) {
123    (
124        usage_rs::render_failure(StartFallback::spec(), argv, error),
125        2,
126    )
127}
128
129pub async fn run() -> Result<()> {
130    let argv: Vec<OsString> = std::env::args_os().collect();
131    if argv.get(1).and_then(|arg| arg.to_str()) == Some("__complete_word__") {
132        if let Some(answer) = completion::app().completion_request(&argv[1..]).await {
133            print!("{answer}");
134        }
135        return Ok(());
136    }
137
138    let args = Cli::parse();
139    match args.command {
140        Commands::Activate(activate) => activate.run().await,
141        Commands::Boot(boot) => boot.run().await,
142        Commands::Cd(cd) => cd.run().await,
143        Commands::Clean(clean) => clean.run().await,
144        Commands::Daemons(daemons) => daemons.run().await,
145        Commands::Completion(completion) => completion.run().await,
146        Commands::Config(config) => config.run().await,
147        Commands::Disable(disable) => disable.run().await,
148        Commands::Enable(enable) => enable.run().await,
149        Commands::List(list) => list.run().await,
150        Commands::LogSink(log_sink) => log_sink.run().await,
151        Commands::Logs(logs) => logs.run().await,
152        Commands::Mcp(mcp) => mcp.run().await,
153        Commands::Proxy(proxy) => proxy.run().await,
154        Commands::Project(project) => project.run().await,
155        Commands::Restart(restart) => restart.run().await,
156        Commands::Run(run) => run.run().await,
157        Commands::ApiSchema(api_schema) => api_schema.run().await,
158        Commands::Schema(schema) => schema.run().await,
159        Commands::Settings(settings) => settings.run().await,
160        Commands::Sponsors(_) => sponsors::Sponsors::run().await,
161        Commands::Start(start) => start.run().await,
162        Commands::Status(status) => status.run().await,
163        Commands::Stop(stop) => stop.run().await,
164        Commands::Supervisor(supervisor) => supervisor.run().await,
165        Commands::Tui(tui) => tui.run().await,
166        Commands::Usage(usage) => usage.run().await,
167        Commands::Wait(wait) => wait.run().await,
168        Commands::Fallback(tokens) => {
169            let argv: Vec<_> = tokens.iter().map(OsString::as_os_str).collect();
170            match StartFallback::parse_from(&argv) {
171                Ok(fallback) => fallback.start.run().await,
172                Err(usage_rs::Error::Help { cmd, long }) => {
173                    let rendered = usage_rs::help::render(StartFallback::spec(), cmd, long)
174                        .expect("derived help metadata should render");
175                    let mut stdout = tokio::io::stdout();
176                    stdout
177                        .write_all(rendered.as_bytes())
178                        .await
179                        .into_diagnostic()?;
180                    stdout.flush().await.into_diagnostic()?;
181                    Ok(())
182                }
183                Err(usage_rs::Error::Version { .. }) => {
184                    let rendered = format!("pitchfork {}\n", env!("CARGO_PKG_VERSION"));
185                    let mut stdout = tokio::io::stdout();
186                    stdout
187                        .write_all(rendered.as_bytes())
188                        .await
189                        .into_diagnostic()?;
190                    stdout.flush().await.into_diagnostic()?;
191                    Ok(())
192                }
193                Err(error) => {
194                    let (rendered, status) = fallback_failure(&argv, &error);
195                    eprint!("{rendered}");
196                    std::process::exit(status);
197                }
198            }
199        }
200    }
201}
202
203/// Drain and display any pending notifications from the supervisor.
204///
205/// Notifications are queued by the supervisor for events that happen
206/// asynchronously (e.g. proxy bind failure) and would otherwise be invisible
207/// to CLI users.  Call this at the end of user-facing commands that connect
208/// to the supervisor via IPC.
209pub(crate) async fn drain_notifications(ipc: &crate::ipc::client::IpcClient) {
210    use log::LevelFilter;
211    if let Ok(notifications) = ipc.get_notifications().await {
212        for (level, msg) in notifications {
213            match level {
214                LevelFilter::Trace => trace!("{msg}"),
215                LevelFilter::Debug => debug!("{msg}"),
216                LevelFilter::Info => info!("{msg}"),
217                LevelFilter::Warn => warn!("{msg}"),
218                LevelFilter::Error => error!("{msg}"),
219                _ => {}
220            }
221        }
222    }
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228    use std::ffi::OsStr;
229
230    #[test]
231    fn bare_invocation_requests_help_instead_of_entering_the_start_fallback() {
232        assert!(matches!(
233            Cli::parse_from(&[]),
234            Err(usage_rs::Error::MissingArgsHelp { .. })
235        ));
236    }
237
238    #[test]
239    fn help_topics_are_claimed_before_the_external_start_fallback() {
240        assert!(matches!(
241            Cli::parse_from(&[OsStr::new("help")]),
242            Err(usage_rs::Error::Help { .. })
243        ));
244        assert!(matches!(
245            Cli::parse_from(&[OsStr::new("help"), OsStr::new("start")]),
246            Err(usage_rs::Error::Help { .. })
247        ));
248    }
249
250    #[test]
251    fn unknown_subcommand_captured_as_fallback() {
252        let argv = [OsStr::new("mydaemon"), OsStr::new("--force")];
253        let args = Cli::parse_from(&argv).unwrap();
254        match args.command {
255            Commands::Fallback(tokens) => {
256                assert_eq!(
257                    tokens,
258                    vec![OsString::from("mydaemon"), OsString::from("--force")]
259                );
260            }
261            _ => panic!("expected Fallback variant, got {:?}", args.command),
262        }
263    }
264
265    #[test]
266    fn unknown_subcommand_captures_multiple_args() {
267        let argv = [
268            OsStr::new("api"),
269            OsStr::new("worker"),
270            OsStr::new("--force"),
271        ];
272        let args = Cli::parse_from(&argv).unwrap();
273        match args.command {
274            Commands::Fallback(tokens) => {
275                assert_eq!(
276                    tokens,
277                    vec![
278                        OsString::from("api"),
279                        OsString::from("worker"),
280                        OsString::from("--force")
281                    ]
282                );
283            }
284            _ => panic!("expected Fallback variant, got {:?}", args.command),
285        }
286    }
287
288    #[test]
289    fn known_start_parses_as_start() {
290        let argv = [OsStr::new("start"), OsStr::new("mydaemon")];
291        let args = Cli::parse_from(&argv).unwrap();
292        match args.command {
293            Commands::Start(_) => {}
294            _ => panic!("expected Start variant, got {:?}", args.command),
295        }
296    }
297
298    #[test]
299    fn start_alias_still_works() {
300        let argv = [OsStr::new("s"), OsStr::new("mydaemon")];
301        let args = Cli::parse_from(&argv).unwrap();
302        match args.command {
303            Commands::Start(_) => {}
304            _ => panic!("expected Start variant, got {:?}", args.command),
305        }
306    }
307
308    #[test]
309    fn fallback_reparse_as_start() {
310        let argv = [OsStr::new("mydaemon"), OsStr::new("--force")];
311        StartFallback::parse_from(&argv).expect("should re-parse captured tokens as Start");
312    }
313
314    #[test]
315    fn fallback_reparse_rejects_invalid_start_flag() {
316        let argv = [OsStr::new("mydaemon"), OsStr::new("--not-a-start-flag")];
317        let result = StartFallback::parse_from(&argv);
318        assert!(
319            result.is_err(),
320            "expected re-parse to fail for invalid Start flag"
321        );
322    }
323
324    #[test]
325    fn fallback_invalid_start_usage_renders_pitchfork_start() {
326        let argv = [OsStr::new("mydaemon"), OsStr::new("--not-a-start-flag")];
327        let err = StartFallback::parse_from(&argv).unwrap_err();
328        let (rendered, status) = fallback_failure(&argv, &err);
329        assert_eq!(status, 2);
330        assert!(
331            rendered.contains("Usage: pitchfork start"),
332            "expected usage to contain 'pitchfork start', got: {rendered}"
333        );
334    }
335
336    #[test]
337    fn fallback_help_shows_start_long_about() {
338        let argv = [OsStr::new("mydaemon"), OsStr::new("--help")];
339        let usage_rs::Error::Help { cmd, long } = StartFallback::parse_from(&argv).unwrap_err()
340        else {
341            panic!("expected help")
342        };
343        let rendered = usage_rs::help::render(StartFallback::spec(), cmd, long).unwrap();
344        assert!(
345            rendered.contains("Examples:"),
346            "expected help to include Start long_about examples, got: {rendered}"
347        );
348        assert!(
349            rendered.contains("pitchfork start api"),
350            "expected help to reference `pitchfork start api`, got: {rendered}"
351        );
352    }
353}