Skip to main content

broccoli_cli/commands/plugin/
build.rs

1use std::path::PathBuf;
2use std::process::Command;
3
4use anyhow::{Context, bail};
5use clap::Args;
6use console::style;
7use serde::Deserialize;
8
9use crate::dev_config;
10
11use super::wasm::copy_wasm_artifact;
12
13/// Builds a plugin's backend (Rust/WASM) and/or frontend components.
14///
15/// The frontend directory and build command can be customized via
16/// `broccoli.dev.toml` in the plugin directory:
17///
18///   [build]
19///   frontend_dir = "client"        # where to run the build command
20///   frontend_cmd = "npm run build" # default: "pnpm build"
21///
22/// Without a config file, the frontend directory is auto-detected from
23/// the [web].root field in plugin.toml, or by looking for package.json
24/// in web/, frontend/, or the plugin root.
25#[derive(Args)]
26pub struct BuildPluginArgs {
27    /// Path to the plugin directory (defaults to current directory)
28    #[arg(default_value = ".")]
29    pub path: PathBuf,
30
31    /// Build in release mode (optimized)
32    #[arg(long)]
33    pub release: bool,
34}
35
36/// Minimal manifest struct — avoids pulling in plugin-core's transitive deps.
37#[derive(Deserialize)]
38struct MinimalManifest {
39    name: Option<String>,
40    server: Option<ServerSection>,
41    web: Option<WebSection>,
42}
43
44#[derive(Deserialize)]
45struct ServerSection {
46    entry: String,
47}
48
49#[derive(Deserialize)]
50struct WebSection {
51    root: String,
52    #[allow(dead_code)]
53    entry: String,
54}
55
56pub fn run(args: BuildPluginArgs) -> anyhow::Result<()> {
57    let plugin_dir = args
58        .path
59        .canonicalize()
60        .with_context(|| format!("Cannot find directory '{}'", args.path.display()))?;
61
62    let manifest_path = plugin_dir.join("plugin.toml");
63    if !manifest_path.exists() {
64        bail!(
65            "Not a broccoli plugin directory: no plugin.toml found in '{}'.\n\
66             Run `broccoli plugin new` to create a new plugin.",
67            plugin_dir.display()
68        );
69    }
70
71    let manifest_content =
72        std::fs::read_to_string(&manifest_path).context("Failed to read plugin.toml")?;
73    let manifest: MinimalManifest =
74        toml::from_str(&manifest_content).context("Failed to parse plugin.toml")?;
75
76    let plugin_name = manifest.name.as_deref().unwrap_or("plugin");
77    let mut built_anything = false;
78
79    // Build backend (Rust/WASM)
80    if let Some(server) = manifest.server.as_ref() {
81        println!(
82            "{}  Building backend for {}...",
83            style("→").blue().bold(),
84            style(plugin_name).cyan()
85        );
86
87        let mut cargo_args = vec!["build", "--target", "wasm32-wasip1"];
88        if args.release {
89            cargo_args.push("--release");
90        }
91
92        let status = Command::new("cargo")
93            .args(&cargo_args)
94            .current_dir(&plugin_dir)
95            .status()
96            .context("Failed to run cargo build. Is Rust installed?")?;
97
98        if !status.success() {
99            bail!("Backend build failed");
100        }
101
102        copy_wasm_artifact(&plugin_dir, &server.entry, args.release)?;
103
104        println!("{}  Backend build complete", style("✓").green().bold());
105        built_anything = true;
106    }
107
108    // Build frontend
109    if manifest.web.is_some() {
110        let web_root = manifest.web.as_ref().map(|w| w.root.as_str());
111        let dev = dev_config::resolve(&plugin_dir, web_root);
112
113        let fe_dir = dev.frontend_dir.unwrap_or_else(|| plugin_dir.clone());
114
115        if !fe_dir.exists() {
116            bail!(
117                "Frontend directory '{}' does not exist.\n\
118                 Check build.frontend_dir in broccoli.dev.toml.",
119                fe_dir.display()
120            );
121        }
122
123        println!(
124            "{}  Building frontend for {}...",
125            style("→").blue().bold(),
126            style(plugin_name).cyan()
127        );
128
129        let (program, cmd_args) = dev
130            .frontend_cmd
131            .split_first()
132            .context("frontend_cmd is empty in broccoli.dev.toml")?;
133
134        let status = Command::new(program)
135            .args(cmd_args)
136            .current_dir(&fe_dir)
137            .status()
138            .with_context(|| {
139                format!(
140                    "Failed to run '{}'. Is it installed?",
141                    dev.frontend_cmd.join(" ")
142                )
143            })?;
144
145        if !status.success() {
146            bail!("Frontend build failed");
147        }
148
149        println!("{}  Frontend build complete", style("✓").green().bold());
150        built_anything = true;
151    }
152
153    if !built_anything {
154        println!(
155            "{}  plugin.toml has no [server] or [web] section — nothing to build.",
156            style("!").yellow().bold()
157        );
158    }
159
160    Ok(())
161}