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