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#[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
200pub(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}