Skip to main content

broccoli_cli/commands/plugin/
watch.rs

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/// Watches plugin source files and auto-rebuilds + uploads on changes.
21///
22/// For plugins with a `[web]` section, the watch command spawns the frontend
23/// dev server (`pnpm dev` by default, configurable via `broccoli.dev.toml`)
24/// as a long-running background process. tsup's built-in `--watch` mode
25/// handles incremental frontend rebuilds; the CLI watches the `[web].root`
26/// output directory for changes and triggers package + upload when new
27/// build artifacts appear.
28///
29/// For backend changes (`.rs`, `.toml`), the CLI runs `cargo build` itself
30/// and then packages + uploads.
31///
32/// Create a `broccoli.dev.toml` in the plugin directory to customize behavior:
33///
34///   [watch]
35///   ignore = ["*.log", "tmp/"]         # extra patterns to ignore
36///
37///   [build]
38///   frontend_dir = "client"            # frontend source directory
39///   frontend_cmd = "npm run build"     # one-shot build (default: "pnpm build")
40///   frontend_dev_cmd = "npm run dev"   # dev server (default: "pnpm dev")
41///
42/// Built-in ignores (always active): target/, .git/, node_modules/.
43#[derive(Args)]
44pub struct WatchPluginArgs {
45    /// Path to the plugin directory
46    #[arg(default_value = ".")]
47    pub path: PathBuf,
48
49    /// Broccoli server URL (overrides saved credentials)
50    #[arg(long, env = "BROCCOLI_URL")]
51    pub server: Option<String>,
52
53    /// Auth token (overrides saved credentials)
54    #[arg(long, env = "BROCCOLI_TOKEN")]
55    pub token: Option<String>,
56
57    /// Build in release mode
58    #[arg(long)]
59    pub release: bool,
60
61    /// Debounce interval in milliseconds
62    #[arg(long, default_value = "500")]
63    pub debounce: u64,
64}
65
66/// Minimal manifest struct (only fields we need for watch/build/package).
67#[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
88/// What triggered the change and what action to take.
89enum ChangeKind {
90    /// Backend source changed. Run cargo build, then package + upload.
91    Backend,
92    /// Frontend dist output changed (from tsup --watch). Just package + upload.
93    FrontendOutput,
94    /// plugin.toml changed. Rebuild backend + package + upload.
95    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    // Install Ctrl+C handler to kill the child process
158    let child_id = fe_child.as_ref().map(|c| c.id());
159    ctrlc::set_handler(move || {
160        if let Some(pid) = child_id {
161            // Best-effort kill. The process may have already exited
162            #[cfg(unix)]
163            {
164                unsafe {
165                    libc::kill(pid as i32, libc::SIGTERM);
166                }
167            }
168            #[cfg(not(unix))]
169            {
170                let _ = pid; // suppress unused warning
171            }
172        }
173        std::process::exit(0);
174    })
175    .ok(); // Ignore error if handler already set
176
177    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                    // Never ignore the web root output dir. We need to detect
217                    // changes from tsup's --watch mode. Only ignore built-in
218                    // dirs (target/, .git/, node_modules/) and extra patterns.
219                    if dev_config::should_ignore(
220                        relative,
221                        &dev.extra_ignores,
222                        None, // Don't ignore web root
223                    ) {
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
345/// Classify a batch of changed files into a single action to take.
346fn 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        // Check if this is inside the web root output directory
369        if web_root_abs.is_some_and(|wr| path.starts_with(wr)) {
370            has_frontend_output = true;
371            continue;
372        }
373
374        // Check if this is a frontend source file (inside frontend_dir).
375        // We ignore these because tsup --watch handles rebuilds.
376        if frontend_dir.is_some_and(|fd| path.starts_with(fd)) {
377            continue;
378        }
379
380        // Everything else is backend-relevant
381        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        // Unknown files changed. Treat as backend to be safe
394        ChangeKind::Backend
395    }
396}
397
398/// Spawn the frontend dev server (e.g. `pnpm dev` which runs `tsup --watch`).
399fn 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
434/// Initial build: build backend + one-shot frontend build + package + upload.
435fn 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
465/// Backend change: cargo build + copy wasm + package + upload.
466fn 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
491/// Frontend output change: just package + upload (no build needed).
492fn 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
529/// One-shot frontend build (used for initial build only).
530fn 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    // Include translation files
591    for path in manifest.translations.values() {
592        add_file_to_tar(&mut builder, plugin_dir, path, plugin_id)?;
593    }
594
595    // Include config directory
596    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    // Compress with gzip
604    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(()); // Skip missing files
620    }
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}