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, install command, 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_install_cmd = "npm install" # default: "pnpm install --ignore-workspace"
21///   frontend_build_cmd = "npm run build" # default: "pnpm build"
22///
23/// Without a config file, the frontend directory is auto-detected from
24/// the [web].root field in plugin.toml, or by looking for package.json
25/// in web/, frontend/, or the plugin root.
26#[derive(Args)]
27pub struct BuildPluginArgs {
28    /// Path to the plugin directory (defaults to current directory)
29    #[arg(default_value = ".")]
30    pub path: PathBuf,
31
32    /// Force execution of the frontend installation command even if node_modules exists
33    #[arg(long)]
34    pub install: bool,
35
36    /// Build in release mode (optimized)
37    #[arg(long)]
38    pub release: bool,
39}
40
41/// Minimal manifest struct — avoids pulling in plugin-core's transitive deps.
42#[derive(Deserialize)]
43struct MinimalManifest {
44    name: Option<String>,
45    server: Option<ServerSection>,
46    web: Option<WebSection>,
47}
48
49#[derive(Deserialize)]
50struct ServerSection {
51    entry: String,
52}
53
54#[derive(Deserialize)]
55struct WebSection {
56    root: String,
57    #[allow(dead_code)]
58    entry: String,
59}
60
61pub fn run(args: BuildPluginArgs) -> anyhow::Result<()> {
62    let plugin_dir = args
63        .path
64        .canonicalize()
65        .with_context(|| format!("Cannot find directory '{}'", args.path.display()))?;
66
67    let manifest_path = plugin_dir.join("plugin.toml");
68    if !manifest_path.exists() {
69        bail!(
70            "Not a broccoli plugin directory: no plugin.toml found in '{}'.\n\
71             Run `broccoli plugin new` to create a new plugin.",
72            plugin_dir.display()
73        );
74    }
75
76    let manifest_content =
77        std::fs::read_to_string(&manifest_path).context("Failed to read plugin.toml")?;
78    let manifest: MinimalManifest =
79        toml::from_str(&manifest_content).context("Failed to parse plugin.toml")?;
80
81    let plugin_name = manifest.name.as_deref().unwrap_or("plugin");
82    let mut built_anything = false;
83
84    // Build backend (Rust/WASM)
85    if let Some(server) = manifest.server.as_ref() {
86        println!(
87            "{}  Building backend for {}...",
88            style("→").blue().bold(),
89            style(plugin_name).cyan()
90        );
91
92        let mut cargo_args = vec!["build", "--target", "wasm32-wasip1"];
93        if args.release {
94            cargo_args.push("--release");
95        }
96
97        let status = Command::new("cargo")
98            .args(&cargo_args)
99            .current_dir(&plugin_dir)
100            .status()
101            .context("Failed to run cargo build. Is Rust installed?")?;
102
103        if !status.success() {
104            bail!("Backend build failed");
105        }
106
107        copy_wasm_artifact(&plugin_dir, &server.entry, args.release)?;
108
109        println!("{}  Backend build complete", style("✓").green().bold());
110        built_anything = true;
111    }
112
113    // Build frontend
114    if manifest.web.is_some() {
115        let web_root = manifest.web.as_ref().map(|w| w.root.as_str());
116        let dev = dev_config::resolve(&plugin_dir, web_root);
117
118        let fe_dir = dev.frontend_dir.unwrap_or_else(|| plugin_dir.clone());
119
120        if !fe_dir.exists() {
121            bail!(
122                "Frontend directory '{}' does not exist.\n\
123                 Check build.frontend_dir in broccoli.dev.toml.",
124                fe_dir.display()
125            );
126        }
127
128        // Install frontend dependencies if node_modules is missing or install flag is set
129        let node_modules_exists = fe_dir.join("node_modules").exists();
130        if !node_modules_exists || args.install {
131            let install_cmd_str = dev.frontend_install_cmd.join(" ");
132
133            if args.install {
134                println!(
135                    "{}  Running '{}' in {}...",
136                    style("→").blue().bold(),
137                    style(&install_cmd_str).cyan(),
138                    fe_dir.display()
139                );
140            } else {
141                println!(
142                    "{}  node_modules not found. Auto-running '{}'...",
143                    style("!").yellow().bold(),
144                    style(&install_cmd_str).cyan()
145                );
146            }
147
148            let (program, cmd_args) = dev
149                .frontend_install_cmd
150                .split_first()
151                .context("frontend_install_cmd is empty in broccoli.dev.toml")?;
152
153            let status = Command::new(program)
154                .args(cmd_args)
155                .current_dir(&fe_dir)
156                .status()
157                .with_context(|| format!("Failed to run '{}'", install_cmd_str))?;
158
159            if !status.success() {
160                bail!("Frontend installation failed");
161            }
162            println!("{}  Dependencies installed", style("✓").green().bold());
163        }
164
165        println!(
166            "{}  Building frontend for {}...",
167            style("→").blue().bold(),
168            style(plugin_name).cyan()
169        );
170
171        let (program, cmd_args) = dev
172            .frontend_build_cmd
173            .split_first()
174            .context("frontend_build_cmd is empty in broccoli.dev.toml")?;
175
176        let status = Command::new(program)
177            .args(cmd_args)
178            .current_dir(&fe_dir)
179            .status()
180            .with_context(|| {
181                format!(
182                    "Failed to run '{}'. Is it installed?",
183                    dev.frontend_build_cmd.join(" ")
184                )
185            })?;
186
187        if !status.success() {
188            bail!("Frontend build failed");
189        }
190
191        println!("{}  Frontend build complete", style("✓").green().bold());
192        built_anything = true;
193    }
194
195    if !built_anything {
196        println!(
197            "{}  plugin.toml has no [server] or [web] section — nothing to build.",
198            style("!").yellow().bold()
199        );
200    }
201
202    Ok(())
203}