This repository has been archived on 2026-10-08. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
maestro/docs/superpowers/plans/2026-05-28-maestro-web-bundle-build.md
T

13 KiB
Raw Blame History

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: guarantee web/dist is fresh before compile.
  • Modify crates/maestro-installer/src/main.rs — add a pure classify_web_prereq decision + frontend_toolchain_present probe, and wire the abort/skip prompt into handle_web.
  • Modify .gitea/workflows/ci.yml — install Node + pnpm so the workspace compiles maestro-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 (add WebPrereq enum + classify_web_prereq + frontend_toolchain_present; wire into handle_web at 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), and frontend_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.