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