1use std::collections::HashSet;
2use std::hash::{Hash, Hasher};
3use std::io::Write as IoWrite;
4use std::path::{Path, PathBuf};
5use std::process::{Child, Command, Stdio};
6use std::sync::mpsc;
7use std::time::{Duration, Instant};
8
9use anyhow::{Context, bail};
10use clap::Args;
11use console::style;
12use notify::{Event, RecursiveMode, Watcher};
13use serde::Deserialize;
14
15use crate::auth;
16use crate::dev_config::{self, FileKind, ResolvedDevConfig};
17
18use super::wasm::copy_wasm_artifact;
19
20#[derive(Args)]
45pub struct WatchPluginArgs {
46 #[arg(default_value = ".")]
48 pub path: PathBuf,
49
50 #[arg(long, env = "BROCCOLI_URL")]
52 pub server: Option<String>,
53
54 #[arg(long, env = "BROCCOLI_TOKEN")]
56 pub token: Option<String>,
57
58 #[arg(long)]
60 pub install: bool,
61
62 #[arg(long)]
64 pub release: bool,
65
66 #[arg(long, default_value = "500")]
68 pub debounce: u64,
69}
70
71#[derive(Deserialize)]
73struct WatchManifest {
74 name: Option<String>,
75 server: Option<ServerSection>,
76 web: Option<WebSection>,
77 #[serde(default)]
78 translations: std::collections::HashMap<String, String>,
79}
80
81#[derive(Deserialize)]
82struct ServerSection {
83 entry: String,
84}
85
86#[derive(Deserialize)]
87struct WebSection {
88 root: String,
89 #[allow(dead_code)]
90 entry: String,
91}
92
93enum ChangeKind {
95 Backend,
97 FrontendOutput,
99 ManifestChanged,
101}
102
103pub fn run(args: WatchPluginArgs) -> anyhow::Result<()> {
104 let plugin_dir = args
105 .path
106 .canonicalize()
107 .with_context(|| format!("Cannot find directory '{}'", args.path.display()))?;
108
109 let manifest_path = plugin_dir.join("plugin.toml");
110 if !manifest_path.exists() {
111 bail!(
112 "Not a broccoli plugin directory: no plugin.toml found in '{}'.\n\
113 Run `broccoli plugin new` to create a new plugin.",
114 plugin_dir.display()
115 );
116 }
117
118 let creds = auth::resolve_credentials(args.server.as_deref(), args.token.as_deref())?;
119
120 let manifest = read_manifest(&manifest_path)?;
121 let plugin_name = manifest.name.as_deref().unwrap_or("plugin");
122
123 println!(
124 "{} Watching plugin {}...",
125 style("→").blue().bold(),
126 style(plugin_name).cyan()
127 );
128 println!(" Server: {}", style(&creds.server).dim());
129
130 let web_root_str = manifest.web.as_ref().map(|w| w.root.as_str());
131 let dev = dev_config::resolve(&plugin_dir, web_root_str);
132
133 let mut web_root_abs = manifest.web.as_ref().map(|w| plugin_dir.join(&w.root));
134 let mut server_entry_abs = manifest.server.as_ref().map(|s| plugin_dir.join(&s.entry));
135 let mut last_uploaded_archive_fingerprint = None;
136
137 let mut fe_child: Option<Child> = None;
138 if manifest.web.is_some() {
139 let fe_dir = dev.frontend_dir.as_deref().unwrap_or(&plugin_dir);
140
141 let node_modules_exists = fe_dir.join("node_modules").exists();
143 if !node_modules_exists || args.install {
144 let install_cmd_str = dev.frontend_install_cmd.join(" ");
145 println!(
146 "{} Preparing frontend dependencies: '{}'...",
147 style("→").blue().bold(),
148 style(&install_cmd_str).cyan()
149 );
150
151 let (prog, args) = dev
152 .frontend_install_cmd
153 .split_first()
154 .context("frontend_install_cmd is empty")?;
155
156 let status = Command::new(prog)
157 .args(args)
158 .current_dir(fe_dir)
159 .status()
160 .with_context(|| format!("Failed to run '{}'", install_cmd_str))?;
161
162 if !status.success() {
163 bail!("Frontend installation failed");
164 }
165 }
166
167 match spawn_frontend_dev(&dev, &plugin_dir) {
168 Ok(child) => {
169 fe_child = Some(child);
170 println!(
171 "{} Frontend dev server started ({})",
172 style("✓").green().bold(),
173 style(dev.frontend_dev_cmd.join(" ")).dim()
174 );
175 }
176 Err(e) => {
177 eprintln!(
178 "{} Failed to start frontend dev server: {}",
179 style("✗").red().bold(),
180 e
181 );
182 eprintln!(
183 " Frontend changes will not be auto-rebuilt.\n\
184 Set build.frontend_dev_cmd in broccoli.dev.toml to customize."
185 );
186 }
187 }
188 }
189
190 let child_id = fe_child.as_ref().map(|c| c.id());
192 ctrlc::set_handler(move || {
193 if let Some(pid) = child_id {
194 #[cfg(unix)]
196 {
197 unsafe {
198 libc::kill(pid as i32, libc::SIGTERM);
199 }
200 }
201 #[cfg(not(unix))]
202 {
203 let _ = pid; }
205 }
206 std::process::exit(0);
207 })
208 .ok(); let (tx, rx) = mpsc::channel();
211 let mut watcher = notify::recommended_watcher(move |res: Result<Event, _>| {
212 if let Ok(event) = res {
213 let _ = tx.send(event);
214 }
215 })
216 .context("Failed to create file watcher")?;
217
218 watcher
219 .watch(&plugin_dir, RecursiveMode::Recursive)
220 .context("Failed to watch plugin directory")?;
221
222 println!(
223 "{} Watching for changes... (Ctrl+C to stop)",
224 style("✓").green().bold()
225 );
226
227 if let Err(e) = initial_build_and_upload(
228 &plugin_dir,
229 &manifest_path,
230 &creds,
231 &dev,
232 args.release,
233 &mut last_uploaded_archive_fingerprint,
234 ) {
235 eprintln!("{} Initial build failed: {}", style("✗").red().bold(), e);
236 }
237
238 let debounce = Duration::from_millis(args.debounce);
239 let mut last_build = Instant::now();
240 let mut pending_changes: HashSet<PathBuf> = HashSet::new();
241 let mut last_manifest_fingerprint = fingerprint_file(&manifest_path)?;
242
243 loop {
244 match rx.recv_timeout(debounce) {
245 Ok(event) => {
246 for path in event.paths {
247 let relative = path.strip_prefix(&plugin_dir).unwrap_or(&path);
248
249 if dev_config::should_ignore(
253 relative,
254 &dev.extra_ignores,
255 None, ) {
257 continue;
258 }
259
260 pending_changes.insert(path);
261 }
262 continue;
263 }
264 Err(mpsc::RecvTimeoutError::Timeout) => {
265 if pending_changes.is_empty() {
266 continue;
267 }
268 }
269 Err(mpsc::RecvTimeoutError::Disconnected) => {
270 break;
271 }
272 }
273
274 if last_build.elapsed() < debounce {
275 continue;
276 }
277
278 let change_kind = classify_changes(
279 &pending_changes,
280 &plugin_dir,
281 web_root_abs.as_deref(),
282 server_entry_abs.as_deref(),
283 dev.frontend_dir.as_deref(),
284 );
285 pending_changes.clear();
286
287 let Some(change_kind) = change_kind else {
288 continue;
290 };
291 last_build = Instant::now();
292
293 match change_kind {
294 ChangeKind::ManifestChanged => {
295 let current_manifest_fingerprint = fingerprint_file(&manifest_path)?;
296 if current_manifest_fingerprint == last_manifest_fingerprint {
297 continue;
298 }
299 last_manifest_fingerprint = current_manifest_fingerprint;
300
301 println!(
302 "\n{} plugin.toml changed, rebuilding backend...",
303 style("→").blue().bold(),
304 );
305
306 if let Err(e) = backend_build_and_upload(
307 &plugin_dir,
308 &manifest_path,
309 &creds,
310 args.release,
311 &mut last_uploaded_archive_fingerprint,
312 ) {
313 eprintln!("{} Build/upload failed: {}", style("✗").red().bold(), e);
314 eprintln!(" Waiting for next change...");
315 } else if let Ok(updated_manifest) = read_manifest(&manifest_path) {
316 web_root_abs = updated_manifest
317 .web
318 .as_ref()
319 .map(|w| plugin_dir.join(&w.root));
320 server_entry_abs = updated_manifest
321 .server
322 .as_ref()
323 .map(|s| plugin_dir.join(&s.entry));
324 }
325 }
326 ChangeKind::Backend => {
327 println!(
328 "\n{} Backend changes detected, rebuilding...",
329 style("→").blue().bold(),
330 );
331
332 if let Err(e) = backend_build_and_upload(
333 &plugin_dir,
334 &manifest_path,
335 &creds,
336 args.release,
337 &mut last_uploaded_archive_fingerprint,
338 ) {
339 eprintln!("{} Build/upload failed: {}", style("✗").red().bold(), e);
340 eprintln!(" Waiting for next change...");
341 }
342 }
343 ChangeKind::FrontendOutput => {
344 println!(
345 "\n{} Frontend output changed, uploading...",
346 style("→").blue().bold(),
347 );
348
349 if let Err(e) = package_and_upload(
350 &plugin_dir,
351 &manifest_path,
352 &creds,
353 &mut last_uploaded_archive_fingerprint,
354 ) {
355 eprintln!("{} Upload failed: {}", style("✗").red().bold(), e);
356 eprintln!(" Waiting for next change...");
357 }
358 }
359 }
360 }
361
362 if let Some(mut child) = fe_child {
363 let _ = child.kill();
364 let _ = child.wait();
365 }
366
367 Ok(())
368}
369
370fn read_manifest(path: &Path) -> anyhow::Result<WatchManifest> {
371 let content = std::fs::read_to_string(path).context("Failed to read plugin.toml")?;
372 toml::from_str(&content).context("Failed to parse plugin.toml")
373}
374
375fn fingerprint_file(path: &Path) -> anyhow::Result<u64> {
376 let content =
377 std::fs::read(path).with_context(|| format!("Failed to read '{}'", path.display()))?;
378 let mut hasher = std::collections::hash_map::DefaultHasher::new();
379 content.hash(&mut hasher);
380 Ok(hasher.finish())
381}
382
383fn classify_changes(
385 changed: &HashSet<PathBuf>,
386 plugin_dir: &Path,
387 web_root_abs: Option<&Path>,
388 server_entry_abs: Option<&Path>,
389 frontend_dir: Option<&Path>,
390) -> Option<ChangeKind> {
391 let mut has_backend = false;
392 let mut has_frontend_output = false;
393 let mut has_unknown = false;
394
395 for path in changed {
396 let relative = path.strip_prefix(plugin_dir).unwrap_or(path);
397 let filename = relative.file_name().unwrap_or_default().to_string_lossy();
398
399 if filename == "plugin.toml" {
400 return Some(ChangeKind::ManifestChanged);
401 }
402
403 if server_entry_abs.is_some_and(|entry| path == entry) {
405 continue;
406 }
407
408 if web_root_abs.is_some_and(|wr| path.starts_with(wr)) {
410 has_frontend_output = true;
411 continue;
412 }
413
414 if frontend_dir.is_some_and(|fd| path.starts_with(fd)) {
417 continue;
418 }
419
420 match dev_config::classify_file(path, plugin_dir, frontend_dir) {
422 FileKind::Backend => has_backend = true,
423 FileKind::PluginManifest => return Some(ChangeKind::ManifestChanged),
424 _ => has_unknown = true,
425 }
426 }
427
428 if has_backend {
429 Some(ChangeKind::Backend)
430 } else if has_frontend_output {
431 Some(ChangeKind::FrontendOutput)
432 } else if has_unknown {
433 Some(ChangeKind::Backend)
435 } else {
436 None
439 }
440}
441
442fn spawn_frontend_dev(dev: &ResolvedDevConfig, _plugin_dir: &Path) -> anyhow::Result<Child> {
444 let fe_dir = dev.frontend_dir.as_deref().context(
445 "Cannot determine frontend directory. Set build.frontend_dir in broccoli.dev.toml",
446 )?;
447
448 if !fe_dir.exists() {
449 bail!(
450 "Frontend directory '{}' does not exist.\n\
451 Check build.frontend_dir in broccoli.dev.toml.",
452 fe_dir.display()
453 );
454 }
455
456 let (program, cmd_args) = dev
457 .frontend_dev_cmd
458 .split_first()
459 .context("frontend_dev_cmd is empty in broccoli.dev.toml")?;
460
461 let child = Command::new(program)
462 .args(cmd_args)
463 .current_dir(fe_dir)
464 .stdout(Stdio::inherit())
465 .stderr(Stdio::inherit())
466 .spawn()
467 .with_context(|| {
468 format!(
469 "Failed to run '{}' in '{}'. Is it installed?",
470 dev.frontend_dev_cmd.join(" "),
471 fe_dir.display()
472 )
473 })?;
474
475 Ok(child)
476}
477
478fn initial_build_and_upload(
480 plugin_dir: &Path,
481 manifest_path: &Path,
482 creds: &auth::Credentials,
483 dev: &ResolvedDevConfig,
484 release: bool,
485 last_uploaded_archive_fingerprint: &mut Option<u64>,
486) -> anyhow::Result<()> {
487 let manifest = read_manifest(manifest_path)?;
488
489 if manifest.server.is_some() {
490 build_backend(plugin_dir, release)?;
491
492 if let Some(ref server) = manifest.server {
493 copy_wasm_artifact(plugin_dir, &server.entry, release)?;
494 }
495 }
496
497 if manifest.web.is_some() {
498 build_frontend(dev)?;
499 }
500
501 let archive = package_plugin(plugin_dir, &manifest)?;
502 upload_plugin(&archive, creds, last_uploaded_archive_fingerprint)?;
503
504 println!("{} Plugin uploaded to server", style("✓").green().bold());
505
506 Ok(())
507}
508
509fn backend_build_and_upload(
511 plugin_dir: &Path,
512 manifest_path: &Path,
513 creds: &auth::Credentials,
514 release: bool,
515 last_uploaded_archive_fingerprint: &mut Option<u64>,
516) -> anyhow::Result<()> {
517 let manifest = read_manifest(manifest_path)?;
518
519 if manifest.server.is_some() {
520 build_backend(plugin_dir, release)?;
521
522 if let Some(ref server) = manifest.server {
523 copy_wasm_artifact(plugin_dir, &server.entry, release)?;
524 }
525 }
526
527 let archive = package_plugin(plugin_dir, &manifest)?;
528 upload_plugin(&archive, creds, last_uploaded_archive_fingerprint)?;
529
530 println!("{} Plugin reloaded on server", style("✓").green().bold());
531
532 Ok(())
533}
534
535fn package_and_upload(
537 plugin_dir: &Path,
538 manifest_path: &Path,
539 creds: &auth::Credentials,
540 last_uploaded_archive_fingerprint: &mut Option<u64>,
541) -> anyhow::Result<()> {
542 let manifest = read_manifest(manifest_path)?;
543 let archive = package_plugin(plugin_dir, &manifest)?;
544 upload_plugin(&archive, creds, last_uploaded_archive_fingerprint)?;
545
546 println!("{} Plugin reloaded on server", style("✓").green().bold());
547
548 Ok(())
549}
550
551fn build_backend(plugin_dir: &Path, release: bool) -> anyhow::Result<()> {
552 println!(" {} Building backend...", style("→").blue());
553
554 let mut cargo_args = vec!["build", "--target", "wasm32-wasip1"];
555 if release {
556 cargo_args.push("--release");
557 }
558
559 let status = Command::new("cargo")
560 .args(&cargo_args)
561 .current_dir(plugin_dir)
562 .status()
563 .context("Failed to run cargo build")?;
564
565 if !status.success() {
566 bail!("Backend build failed");
567 }
568
569 println!(" {} Backend build complete", style("✓").green());
570 Ok(())
571}
572
573fn build_frontend(dev: &ResolvedDevConfig) -> anyhow::Result<()> {
575 println!(" {} Building frontend...", style("→").blue());
576
577 let fe_dir = dev.frontend_dir.as_deref().context(
578 "Cannot determine frontend directory. Set build.frontend_dir in broccoli.dev.toml",
579 )?;
580
581 if !fe_dir.exists() {
582 bail!(
583 "Frontend directory '{}' does not exist.\n\
584 Check build.frontend_dir in broccoli.dev.toml.",
585 fe_dir.display()
586 );
587 }
588
589 let (program, cmd_args) = dev
590 .frontend_build_cmd .split_first()
592 .context("frontend_build_cmd is empty in broccoli.dev.toml")?;
593
594 let status = Command::new(program)
595 .args(cmd_args)
596 .current_dir(fe_dir)
597 .status()
598 .with_context(|| {
599 format!(
600 "Failed to run '{}'. Is it installed?",
601 dev.frontend_build_cmd.join(" ")
602 )
603 })?;
604
605 if !status.success() {
606 bail!("Frontend build failed");
607 }
608
609 println!(" {} Frontend build complete", style("✓").green());
610 Ok(())
611}
612
613fn package_plugin(plugin_dir: &Path, manifest: &WatchManifest) -> anyhow::Result<Vec<u8>> {
614 let plugin_id = plugin_dir
615 .file_name()
616 .and_then(|n| n.to_str())
617 .context("Invalid plugin directory name")?;
618
619 let mut builder = tar::Builder::new(Vec::new());
620
621 add_file_to_tar(&mut builder, plugin_dir, "plugin.toml", plugin_id)?;
622
623 if let Some(ref server) = manifest.server {
624 add_file_to_tar(&mut builder, plugin_dir, &server.entry, plugin_id)?;
625 }
626
627 if let Some(ref web) = manifest.web {
628 let web_root = plugin_dir.join(&web.root);
629 if web_root.exists() {
630 add_dir_to_tar(&mut builder, plugin_dir, &web.root, plugin_id)?;
631 }
632 }
633
634 for path in manifest.translations.values() {
636 add_file_to_tar(&mut builder, plugin_dir, path, plugin_id)?;
637 }
638
639 let config_dir = plugin_dir.join("config");
641 if config_dir.exists() {
642 add_dir_to_tar(&mut builder, plugin_dir, "config", plugin_id)?;
643 }
644
645 let tar_data = builder.into_inner().context("Failed to finalize tar")?;
646
647 use flate2::Compression;
649 use flate2::write::GzEncoder;
650 let mut encoder = GzEncoder::new(Vec::new(), Compression::fast());
651 encoder.write_all(&tar_data)?;
652 encoder.finish().context("Failed to compress archive")
653}
654
655fn add_file_to_tar(
656 builder: &mut tar::Builder<Vec<u8>>,
657 base_dir: &Path,
658 relative_path: &str,
659 plugin_id: &str,
660) -> anyhow::Result<()> {
661 let full_path = base_dir.join(relative_path);
662 if !full_path.exists() {
663 return Ok(()); }
665 let tar_path = format!("{}/{}", plugin_id, relative_path);
666 builder
667 .append_path_with_name(&full_path, &tar_path)
668 .with_context(|| format!("Failed to add '{}' to archive", relative_path))?;
669 Ok(())
670}
671
672fn add_dir_to_tar(
673 builder: &mut tar::Builder<Vec<u8>>,
674 base_dir: &Path,
675 relative_dir: &str,
676 plugin_id: &str,
677) -> anyhow::Result<()> {
678 let full_dir = base_dir.join(relative_dir);
679 if !full_dir.exists() {
680 return Ok(());
681 }
682 let tar_prefix = format!("{}/{}", plugin_id, relative_dir);
683 builder
684 .append_dir_all(&tar_prefix, &full_dir)
685 .with_context(|| format!("Failed to add directory '{}' to archive", relative_dir))?;
686 Ok(())
687}
688
689fn fingerprint_bytes(bytes: &[u8]) -> u64 {
690 let mut hasher = std::collections::hash_map::DefaultHasher::new();
691 bytes.hash(&mut hasher);
692 hasher.finish()
693}
694
695const MAX_UPLOAD_RETRIES: u32 = 3;
697
698const INITIAL_RETRY_DELAY: Duration = Duration::from_secs(2);
700
701fn upload_plugin(
702 archive: &[u8],
703 creds: &auth::Credentials,
704 last_uploaded_archive_fingerprint: &mut Option<u64>,
705) -> anyhow::Result<()> {
706 let fingerprint = fingerprint_bytes(archive);
707 if last_uploaded_archive_fingerprint.is_some_and(|last| last == fingerprint) {
708 println!(
709 "{} Plugin output unchanged, skipping upload",
710 style("✓").green().bold()
711 );
712 return Ok(());
713 }
714
715 let client = reqwest::blocking::Client::new();
716 let mut last_err = None;
717
718 for attempt in 0..=MAX_UPLOAD_RETRIES {
719 if attempt > 0 {
720 let delay = INITIAL_RETRY_DELAY * 2u32.pow(attempt - 1);
721 eprintln!(
722 " Retrying upload in {}s (attempt {}/{})...",
723 delay.as_secs(),
724 attempt + 1,
725 MAX_UPLOAD_RETRIES + 1
726 );
727 std::thread::sleep(delay);
728 }
729
730 let form = reqwest::blocking::multipart::Form::new().part(
731 "plugin",
732 reqwest::blocking::multipart::Part::bytes(archive.to_vec())
733 .file_name("plugin.tar.gz")
734 .mime_str("application/gzip")?,
735 );
736
737 let resp = match client
738 .post(format!("{}/api/v1/admin/plugins/upload", creds.server))
739 .bearer_auth(&creds.token)
740 .multipart(form)
741 .send()
742 {
743 Ok(r) => r,
744 Err(e) => {
745 last_err = Some(format!("Connection error: {e}"));
746 continue;
747 }
748 };
749
750 if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
751 bail!(
752 "Authentication failed (401). Your token may have expired.\n\
753 Run `broccoli login` again to refresh your credentials."
754 );
755 }
756
757 if resp.status().is_success() {
758 *last_uploaded_archive_fingerprint = Some(fingerprint);
759 return Ok(());
760 }
761
762 let status = resp.status();
763 let body = resp.text().unwrap_or_default();
764
765 if status.is_server_error() {
767 last_err = Some(format!("Upload failed ({status}): {body}"));
768 continue;
769 }
770
771 bail!("Upload failed ({}): {}", status, body);
772 }
773
774 bail!(
775 "{}. Giving up after {} attempts",
776 last_err.unwrap_or_else(|| "Upload failed".into()),
777 MAX_UPLOAD_RETRIES + 1
778 );
779}