Skip to main content

pitchfork_cli/cli/
mod.rs

1use crate::Result;
2use clap::Parser;
3use std::ffi::OsString;
4
5mod activate;
6mod api_schema;
7mod boot;
8mod cd;
9mod clean;
10mod completion;
11mod daemons;
12mod disable;
13mod enable;
14mod json_output;
15mod list;
16pub mod logs;
17mod mcp;
18mod project;
19mod proxy;
20mod restart;
21mod run;
22mod schema;
23mod settings;
24mod sponsors;
25mod start;
26mod status;
27mod stop;
28mod supervisor;
29mod tui;
30mod usage;
31mod wait;
32
33#[derive(Debug, clap::Parser)]
34#[clap(name = "pitchfork", version = env!("CARGO_PKG_VERSION"), about = env!("CARGO_PKG_DESCRIPTION"))]
35struct Cli {
36    #[clap(subcommand)]
37    command: Commands,
38}
39
40#[derive(Debug, clap::Subcommand)]
41#[allow(clippy::large_enum_variant)]
42enum Commands {
43    Activate(activate::Activate),
44    ApiSchema(api_schema::ApiSchema),
45    Boot(boot::Boot),
46    Cd(cd::Cd),
47    Clean(clean::Clean),
48    Daemons(daemons::Daemons),
49    Completion(completion::Completion),
50    Disable(disable::Disable),
51    Enable(enable::Enable),
52    List(list::List),
53    Logs(logs::Logs),
54    Mcp(mcp::Mcp),
55    Proxy(proxy::Proxy),
56    Project(project::Project),
57    Restart(restart::Restart),
58    Run(run::Run),
59    Schema(schema::Schema),
60    Settings(settings::Settings),
61    Sponsors(sponsors::Sponsors),
62    Start(start::Start),
63    Status(status::Status),
64    Stop(stop::Stop),
65    Supervisor(supervisor::Supervisor),
66    Tui(tui::Tui),
67    Usage(usage::Usage),
68    Wait(wait::Wait),
69    #[clap(external_subcommand)]
70    Fallback(Vec<OsString>),
71}
72
73/// Parses tokens captured by the implicit subcommand fallback as a
74/// `pitchfork start` invocation, so usage/help/error output reflects that.
75#[derive(Debug, clap::Parser)]
76#[clap(
77    name = "pitchfork",
78    bin_name = "pitchfork start",
79    version = env!("CARGO_PKG_VERSION"),
80    long_about = start::LONG_ABOUT
81)]
82struct StartFallback {
83    #[clap(flatten)]
84    start: start::Start,
85}
86
87pub async fn run() -> Result<()> {
88    let args = Cli::parse();
89    let program = std::env::args_os()
90        .next()
91        .unwrap_or_else(|| "pitchfork".into());
92    match args.command {
93        Commands::Activate(activate) => activate.run().await,
94        Commands::Boot(boot) => boot.run().await,
95        Commands::Cd(cd) => cd.run().await,
96        Commands::Clean(clean) => clean.run().await,
97        Commands::Daemons(daemons) => daemons.run().await,
98        Commands::Completion(completion) => completion.run().await,
99        Commands::Disable(disable) => disable.run().await,
100        Commands::Enable(enable) => enable.run().await,
101        Commands::List(list) => list.run().await,
102        Commands::Logs(logs) => logs.run().await,
103        Commands::Mcp(mcp) => mcp.run().await,
104        Commands::Proxy(proxy) => proxy.run().await,
105        Commands::Project(project) => project.run().await,
106        Commands::Restart(restart) => restart.run().await,
107        Commands::Run(run) => run.run().await,
108        Commands::ApiSchema(api_schema) => api_schema.run().await,
109        Commands::Schema(schema) => schema.run().await,
110        Commands::Settings(settings) => settings.run().await,
111        Commands::Sponsors(_) => sponsors::Sponsors::run().await,
112        Commands::Start(start) => start.run().await,
113        Commands::Status(status) => status.run().await,
114        Commands::Stop(stop) => stop.run().await,
115        Commands::Supervisor(supervisor) => supervisor.run().await,
116        Commands::Tui(tui) => tui.run().await,
117        Commands::Usage(usage) => usage.run().await,
118        Commands::Wait(wait) => wait.run().await,
119        Commands::Fallback(tokens) => {
120            let mut argv = vec![program];
121            argv.extend(tokens);
122            StartFallback::parse_from(argv).start.run().await
123        }
124    }
125}
126
127/// Drain and display any pending notifications from the supervisor.
128///
129/// Notifications are queued by the supervisor for events that happen
130/// asynchronously (e.g. proxy bind failure) and would otherwise be invisible
131/// to CLI users.  Call this at the end of user-facing commands that connect
132/// to the supervisor via IPC.
133pub(crate) async fn drain_notifications(ipc: &crate::ipc::client::IpcClient) {
134    use log::LevelFilter;
135    if let Ok(notifications) = ipc.get_notifications().await {
136        for (level, msg) in notifications {
137            match level {
138                LevelFilter::Trace => trace!("{msg}"),
139                LevelFilter::Debug => debug!("{msg}"),
140                LevelFilter::Info => info!("{msg}"),
141                LevelFilter::Warn => warn!("{msg}"),
142                LevelFilter::Error => error!("{msg}"),
143                _ => {}
144            }
145        }
146    }
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152    use clap::Parser;
153
154    #[test]
155    fn unknown_subcommand_captured_as_fallback() {
156        let args = Cli::parse_from(["pitchfork", "mydaemon", "--force"]);
157        match args.command {
158            Commands::Fallback(tokens) => {
159                assert_eq!(
160                    tokens,
161                    vec![OsString::from("mydaemon"), OsString::from("--force")]
162                );
163            }
164            _ => panic!("expected Fallback variant, got {:?}", args.command),
165        }
166    }
167
168    #[test]
169    fn unknown_subcommand_captures_multiple_args() {
170        let args = Cli::parse_from(["pitchfork", "api", "worker", "--force"]);
171        match args.command {
172            Commands::Fallback(tokens) => {
173                assert_eq!(
174                    tokens,
175                    vec![
176                        OsString::from("api"),
177                        OsString::from("worker"),
178                        OsString::from("--force")
179                    ]
180                );
181            }
182            _ => panic!("expected Fallback variant, got {:?}", args.command),
183        }
184    }
185
186    #[test]
187    fn known_start_parses_as_start() {
188        let args = Cli::parse_from(["pitchfork", "start", "mydaemon"]);
189        match args.command {
190            Commands::Start(_) => {}
191            _ => panic!("expected Start variant, got {:?}", args.command),
192        }
193    }
194
195    #[test]
196    fn start_alias_still_works() {
197        let args = Cli::parse_from(["pitchfork", "s", "mydaemon"]);
198        match args.command {
199            Commands::Start(_) => {}
200            _ => panic!("expected Start variant, got {:?}", args.command),
201        }
202    }
203
204    #[test]
205    fn fallback_reparse_as_start() {
206        StartFallback::try_parse_from(["pitchfork", "mydaemon", "--force"])
207            .expect("should re-parse captured tokens as Start");
208    }
209
210    #[test]
211    fn fallback_reparse_rejects_invalid_start_flag() {
212        let result = StartFallback::try_parse_from(["pitchfork", "mydaemon", "--not-a-start-flag"]);
213        assert!(
214            result.is_err(),
215            "expected re-parse to fail for invalid Start flag"
216        );
217    }
218
219    #[test]
220    fn fallback_invalid_start_usage_renders_pitchfork_start() {
221        let err = StartFallback::try_parse_from(["pitchfork", "mydaemon", "--not-a-start-flag"])
222            .unwrap_err();
223        let rendered = err.to_string();
224        assert!(
225            rendered.contains("Usage: pitchfork start"),
226            "expected usage to contain 'pitchfork start', got: {rendered}"
227        );
228    }
229
230    #[test]
231    fn fallback_help_shows_start_long_about() {
232        let err = StartFallback::try_parse_from(["pitchfork", "mydaemon", "--help"]).unwrap_err();
233        let rendered = err.to_string();
234        assert!(
235            rendered.contains("Examples:"),
236            "expected help to include Start long_about examples, got: {rendered}"
237        );
238        assert!(
239            rendered.contains("pitchfork start api"),
240            "expected help to reference `pitchfork start api`, got: {rendered}"
241        );
242    }
243}