docs(maestro): implementation plan — build maestro-web SPA bundle in build.rs
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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 `<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`.
|
||||
|
||||
```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) <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):
|
||||
|
||||
```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) <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:
|
||||
|
||||
```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) <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.
|
||||
Reference in New Issue
Block a user