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