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