Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
13 KiB
maestro-web Bundle Build Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Make every compiled maestro-web binary embed a real SPA bundle by building the frontend in build.rs, so no install method ever serves the "bundle not built" placeholder.
Architecture: A new crates/maestro-web/build.rs runs pnpm install --frozen-lockfile && pnpm build in web/ before include_dir! embeds web/dist; missing pnpm/node hard-fails the build. The installer wizard preflights the toolchain at its opt-in web step and prompts abort/skip when it's absent. CI gains the Node + pnpm toolchain.
Tech Stack: Rust (cargo build scripts, std::process::Command), pnpm 9.15.4 + Node, Gitea Actions CI.
Spec: docs/superpowers/specs/2026-05-28-maestro-web-bundle-build.md
File Structure
- Create
crates/maestro-web/build.rs— builds the SPA bundle and declares frontend rerun triggers. Sole responsibility: guaranteeweb/distis fresh before compile. - Modify
crates/maestro-installer/src/main.rs— add a pureclassify_web_prereqdecision +frontend_toolchain_presentprobe, and wire the abort/skip prompt intohandle_web. - Modify
.gitea/workflows/ci.yml— install Node + pnpm so the workspace compilesmaestro-web.
No changes to static_bundle.rs (the placeholder stays as unreachable defense-in-depth).
Task 1: build.rs builds the SPA bundle at compile time
Files:
-
Create:
crates/maestro-web/build.rs -
Test:
crates/maestro-web/tests/embedded_bundle.rs -
Step 1: Write the failing test
Create crates/maestro-web/tests/embedded_bundle.rs. It asserts the embedded
index is the real SPA (which mounts <div id="app">), not the placeholder
(whose <h1> reads "maestro-web is running"). serve_index is public in
maestro_web::static_bundle via lib.rs.
//! The compiled binary must embed the real SPA bundle, never the placeholder.
//! `build.rs` guarantees `web/dist` is populated before `include_dir!` runs.
use axum::body::to_bytes;
use maestro_web::static_bundle::serve_index;
#[tokio::test]
async fn embedded_index_is_real_spa_not_placeholder() {
let resp = serve_index();
let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
let html = String::from_utf8(body.to_vec()).unwrap();
assert!(
html.contains(r#"id="app""#),
"served index is missing the SPA mount point; got:\n{html}"
);
assert!(
!html.contains("maestro-web is running"),
"served index is the placeholder, not the built bundle"
);
}
static_bundle is already pub mod in crates/maestro-web/src/lib.rs and serve_index is public, so no lib.rs change is needed. The axum and tokio dev-deps are already in Cargo.toml.
- Step 2: Run the test and verify it fails
First force an empty bundle so the test has a true red (this machine already has a built web/dist):
rm -rf web/dist && mkdir -p web/dist && touch web/dist/.gitkeep
cargo test -p maestro-web --test embedded_bundle
Expected: FAIL — serve_index() returns the placeholder, so the id="app" assertion fails. (Without build.rs yet, nothing rebuilds the bundle.)
- Step 3: Write the build script
Create crates/maestro-web/build.rs:
//! Build the embedded SPA bundle before the crate compiles.
//!
//! `static_bundle.rs` embeds `web/dist` at compile time via `include_dir!`.
//! This script guarantees that directory holds a freshly built bundle by
//! running `pnpm install --frozen-lockfile && pnpm build` in `web/`. pnpm and
//! node are HARD requirements — a missing toolchain fails the build with an
//! actionable message rather than silently shipping the placeholder.
use std::env;
use std::path::PathBuf;
use std::process::Command;
fn main() {
let manifest_dir =
PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR set by cargo"));
let web_dir = manifest_dir.join("../../web");
// Rebuild the bundle only when a frontend input changes — not on every
// unrelated Rust recompile.
for rel in ["src", "index.html", "package.json", "pnpm-lock.yaml", "vite.config.ts"] {
println!("cargo:rerun-if-changed={}", web_dir.join(rel).display());
}
require_tool("pnpm");
require_tool("node");
run(
Command::new("pnpm")
.args(["install", "--frozen-lockfile"])
.current_dir(&web_dir),
"pnpm install --frozen-lockfile",
);
run(
Command::new("pnpm")
.arg("build")
.current_dir(&web_dir),
"pnpm build",
);
}
fn require_tool(tool: &str) {
let ok = Command::new(tool)
.arg("--version")
.output()
.map(|o| o.status.success())
.unwrap_or(false);
if !ok {
panic!(
"maestro-web requires `{tool}` to build its embedded SPA bundle. \
Install pnpm + node (https://pnpm.io) or use a prebuilt binary."
);
}
}
fn run(cmd: &mut Command, label: &str) {
let status = cmd
.status()
.unwrap_or_else(|e| panic!("failed to spawn `{label}`: {e}"));
if !status.success() {
panic!("`{label}` failed with {status}");
}
}
- Step 4: Run the test and verify it passes
cargo test -p maestro-web --test embedded_bundle
Expected: PASS — build.rs ran pnpm build, repopulating web/dist, so the
embedded index now contains id="app". Confirm web/dist/assets/ has the
built JS/CSS again.
- Step 5: Lint
cargo clippy -p maestro-web --all-targets -- -D warnings
cargo fmt --all
Expected: clean.
- Step 6: Commit
git add crates/maestro-web/build.rs crates/maestro-web/tests/embedded_bundle.rs
git commit -m "feat(maestro): build maestro-web SPA bundle in build.rs
include_dir! now embeds a freshly built web/dist on every compile; pnpm +
node are hard build requirements. Fixes the placeholder served by
cargo install --git.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
Task 2: Installer preflights the frontend toolchain
Files:
-
Modify:
crates/maestro-installer/src/main.rs(addWebPrereqenum +classify_web_prereq+frontend_toolchain_present; wire intohandle_webat the opt-in point, ~line 389–396) -
Test:
crates/maestro-installer/src/main.rs(new#[cfg(test)] mod tests) -
Step 1: Write the failing test
Append to the end of crates/maestro-installer/src/main.rs (no test module exists yet):
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn prereq_ok_when_toolchain_present() {
assert_eq!(classify_web_prereq(true, true), WebPrereq::Ok);
assert_eq!(classify_web_prereq(true, false), WebPrereq::Ok);
}
#[test]
fn prereq_prompts_when_missing_and_interactive() {
assert_eq!(classify_web_prereq(false, true), WebPrereq::PromptMissing);
}
#[test]
fn prereq_skips_when_missing_and_noninteractive() {
assert_eq!(classify_web_prereq(false, false), WebPrereq::SkipMissing);
}
}
- Step 2: Run the test and verify it fails
cargo test -p maestro-installer classify_web_prereq
Expected: FAIL to compile — WebPrereq and classify_web_prereq don't exist yet.
- Step 3: Add the enum, decision function, and toolchain probe
In crates/maestro-installer/src/main.rs, just above fn handle_web( (line 350), add:
/// What to do at the (opt-in) web step given whether pnpm + node are present.
#[derive(Debug, PartialEq, Eq)]
enum WebPrereq {
/// Toolchain present — install maestro-web normally.
Ok,
/// Missing and we can ask: prompt the operator to abort or skip.
PromptMissing,
/// Missing and we can't prompt (non-interactive refresh): warn + skip.
SkipMissing,
}
fn classify_web_prereq(toolchain_present: bool, interactive: bool) -> WebPrereq {
if toolchain_present {
WebPrereq::Ok
} else if interactive {
WebPrereq::PromptMissing
} else {
WebPrereq::SkipMissing
}
}
/// True iff both `pnpm` and `node` respond to `--version` on PATH. maestro-web's
/// build.rs needs them to build the embedded SPA bundle.
fn frontend_toolchain_present() -> bool {
["pnpm", "node"].iter().all(|tool| {
Command::new(tool)
.arg("--version")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
})
}
- Step 4: Run the unit test and verify it passes
cargo test -p maestro-installer classify_web_prereq
Expected: PASS (3 tests).
- Step 5: Wire the preflight into
handle_web
In handle_web, the existing flow is:
if !want_web {
return Ok(false);
}
// Install the binary. Even if cargo-install fails we keep going so the
// operator can fix the cause and re-run; the existing daemon work
// doesn't need to be undone.
let source = build_source(args);
Insert the preflight between the if !want_web block and let source =:
if !want_web {
return Ok(false);
}
// maestro-web's build.rs requires pnpm + node. Check before cargo install so
// a missing toolchain surfaces a clear choice instead of a raw build panic.
match classify_web_prereq(frontend_toolchain_present(), interactive) {
WebPrereq::Ok => {}
WebPrereq::PromptMissing => {
eprintln!("\nmaestro-web needs pnpm + node, which aren't installed.");
if confirm("Abort the whole install? (No = skip web UI)", false)? {
return Err(
"aborted: maestro-web prerequisites (pnpm + node) are missing".into(),
);
}
eprintln!("Skipping maestro-web install.");
return Ok(false);
}
WebPrereq::SkipMissing => {
eprintln!(
"\nSkipping maestro-web refresh: pnpm + node not installed. \
Existing unit left in place."
);
return Ok(false);
}
}
// Install the binary. Even if cargo-install fails we keep going so the
// operator can fix the cause and re-run; the existing daemon work
// doesn't need to be undone.
let source = build_source(args);
- Step 6: Verify the crate builds, lints, and tests pass
cargo build -p maestro-installer
cargo clippy -p maestro-installer --all-targets -- -D warnings
cargo test -p maestro-installer
cargo fmt --all
Expected: all clean/pass. (Command and confirm are already imported/defined in the file.)
- Step 7: Commit
git add crates/maestro-installer/src/main.rs
git commit -m "feat(maestro): preflight pnpm + node at installer web step
The opt-in maestro-web step now checks for the frontend toolchain its
build.rs requires; missing tools prompt abort/skip (interactive) or skip
the refresh (non-interactive) instead of failing cargo with a raw panic.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
Task 3: CI installs Node + pnpm
Files:
-
Modify:
.gitea/workflows/ci.yml -
Step 1: Add a Node + pnpm step
clippy and test compile maestro-web, which now needs the toolchain. After
the "Install rustup + stable toolchain" step (line 27) and before "Cache
cargo", add:
- name: Install Node.js + pnpm
run: |
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
apt-get install -y --no-install-recommends nodejs
corepack enable
corepack prepare pnpm@9.15.4 --activate
corepack prepare pnpm@9.15.4 matches the packageManager pin in
web/package.json, keeping CI deterministic.
- Step 2: Verify locally that the affected jobs compile
CI can't be run locally, but confirm the commands the runner executes still work on a machine with the toolchain:
cargo clippy --all-targets -- -D warnings
cargo test --all
Expected: clean / all pass (these now exercise maestro-web's build.rs).
- Step 3: Commit and push
git add .gitea/workflows/ci.yml
git commit -m "ci(maestro): install node + pnpm for maestro-web build.rs
Compiling maestro-web now requires the frontend toolchain to build the
embedded SPA bundle.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
git push
- Step 4: Confirm CI is green
Poll the Gitea CI run for this push (per project preference: rely on CI rather
than long local builds). Expected: ci / rust job passes including the new
Node/pnpm step.
Self-Review Notes
- Spec coverage: §1 build.rs → Task 1; §2 static_bundle unchanged → no task (intentional); §3 installer preflight (prompt abort/skip; non-interactive skip) → Task 2; §4 CI → Task 3; §5 Justfile unchanged → no task (intentional).
- Type consistency:
WebPrereq::{Ok,PromptMissing,SkipMissing},classify_web_prereq(bool, bool), andfrontend_toolchain_present()are used identically in the definition (Task 2 Step 3), the wiring (Step 5), and the tests (Step 1). - Placeholders: none — every code/command step is concrete.