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_install_cmd = "pnpm install --ignore-workspace" # install command
40///   frontend_build_cmd = "pnpm build"  # one-shot build (default: "pnpm build")
41///   frontend_dev_cmd = "pnpm dev"      # dev server (default: "pnpm dev")
42///
43/// Built-in ignores (always active): target/, .git/, node_modules/.
44#[derive(Args)]
45pub struct WatchPluginArgs {
46    /// Path to the plugin directory
47    #[arg(default_value = ".")]
48    pub path: PathBuf,
49
50    /// Broccoli server URL (overrides saved credentials)
51    #[arg(long, env = "BROCCOLI_URL")]
52    pub server: Option<String>,
53
54    /// Auth token (overrides saved credentials)
55    #[arg(long, env = "BROCCOLI_TOKEN")]
56    pub token: Option<String>,
57
58    /// Force execution of the frontend installation command even if node_modules exists
59    #[arg(long)]
60    pub install: bool,
61
62    /// Build in release mode
63    #[arg(long)]
64    pub release: bool,
65
66    /// Debounce interval in milliseconds
67    #[arg(long, default_value = "500")]
68    pub debounce: u64,
69}
70
71/// Minimal manifest struct (only fields we need for watch/build/package).
72#[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
93/// What triggered the change and what action to take.
94enum ChangeKind {
95    /// Backend source changed. Run cargo build, then package + upload.
96    Backend,
97    /// Frontend dist output changed (from tsup --watch). Just package + upload.
98    FrontendOutput,
99    /// plugin.toml changed. Rebuild backend + package + upload.
100    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        // Ensure dependencies are installed before starting watch
142        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    // Install Ctrl+C handler to kill the child process
191    let child_id = fe_child.as_ref().map(|c| c.id());
192    ctrlc::set_handler(move || {
193        if let Some(pid) = child_id {
194            // Best-effort kill. The process may have already exited
195            #[cfg(unix)]
196            {
197                unsafe {
198                    libc::kill(pid as i32, libc::SIGTERM);
199                }
200            }
201            #[cfg(not(unix))]
202            {
203                let _ = pid; // suppress unused warning
204            }
205        }
206        std::process::exit(0);
207    })
208    .ok(); // Ignore error if handler already set
209
210    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                    // Never ignore the web root output dir. We need to detect
250                    // changes from tsup's --watch mode. Only ignore built-in
251                    // dirs (target/, .git/, node_modules/) and extra patterns.
252                    if dev_config::should_ignore(
253                        relative,
254                        &dev.extra_ignores,
255                        None, // Don't ignore web root
256                    ) {
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            // All changes were build artifacts (e.g. copied WASM entry). Skip.
289            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
383/// Classify a batch of changed files into a single action to take.
384fn 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        // Skip the WASM entry artifact (written by copy_wasm_artifact)
404        if server_entry_abs.is_some_and(|entry| path == entry) {
405            continue;
406        }
407
408        // Check if this is inside the web root output directory
409        if web_root_abs.is_some_and(|wr| path.starts_with(wr)) {
410            has_frontend_output = true;
411            continue;
412        }
413
414        // Check if this is a frontend source file (inside frontend_dir).
415        // We ignore these because tsup --watch handles rebuilds.
416        if frontend_dir.is_some_and(|fd| path.starts_with(fd)) {
417            continue;
418        }
419
420        // Everything else is backend-relevant
421        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        // Unknown files changed outside known dirs. Treat as backend to be safe.
434        Some(ChangeKind::Backend)
435    } else {
436        // All changed files were explicitly skipped (e.g. only the WASM entry
437        // artifact was updated by copy_wasm_artifact). No rebuild needed.
438        None
439    }
440}
441
442/// Spawn the frontend dev server (e.g. `pnpm dev` which runs `tsup --watch`).
443fn 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
478/// Initial build: build backend + one-shot frontend build + package + upload.
479fn 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
509/// Backend change: cargo build + copy wasm + package + upload.
510fn 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
535/// Frontend output change: just package + upload (no build needed).
536fn 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
573/// One-shot frontend build (used for initial build only).
574fn 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 // Updated name
591        .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    // Include translation files
635    for path in manifest.translations.values() {
636        add_file_to_tar(&mut builder, plugin_dir, path, plugin_id)?;
637    }
638
639    // Include config directory
640    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    // Compress with gzip
648    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(()); // Skip missing files
664    }
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
695/// Maximum number of upload retries for transient failures.
696const MAX_UPLOAD_RETRIES: u32 = 3;
697
698/// Initial retry delay (doubles each attempt).
699const 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        // Only retry on server errors (5xx) — client errors won't self-resolve
766        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}