diff --git a/docs/superpowers/plans/2026-05-28-maestro-web-bundle-build.md b/docs/superpowers/plans/2026-05-28-maestro-web-bundle-build.md new file mode 100644 index 0000000..8f6b58f --- /dev/null +++ b/docs/superpowers/plans/2026-05-28-maestro-web-bundle-build.md @@ -0,0 +1,403 @@ +# 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 `
`), not the placeholder +(whose `

` reads "maestro-web is running"). `serve_index` is public in +`maestro_web::static_bundle` via `lib.rs`. + +```rust +//! 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`): + +```bash +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`: + +```rust +//! 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** + +```bash +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** + +```bash +cargo clippy -p maestro-web --all-targets -- -D warnings +cargo fmt --all +``` + +Expected: clean. + +- [ ] **Step 6: Commit** + +```bash +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) " +``` + +--- + +## 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): + +```rust +#[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** + +```bash +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: + +```rust +/// 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** + +```bash +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: + +```rust + 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 =`: + +```rust + 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** + +```bash +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** + +```bash +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) " +``` + +--- + +## 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: + +```yaml + - 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: + +```bash +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** + +```bash +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) " +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.