From f8f1a9aee5cb482d217e9ae5e2662694f63ed989 Mon Sep 17 00:00:00 2001 From: slhx agent Date: Fri, 12 Jun 2026 12:46:06 +0200 Subject: [PATCH] feat(workout): create standalone app path Add a workout new PATH command that copies the Workout exemplar into a standalone app with local hemx path dependencies, verifies its manifest in xtask tests, and documents the create/check path. req: examples/001 req: examples/006 --- README.md | 3 +- examples/workout/README.md | 11 +++ hemx-xtask/src/main.rs | 154 ++++++++++++++++++++++++++++++++++++- 3 files changed, 163 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 07b58e1..1ed7d31 100644 --- a/README.md +++ b/README.md @@ -133,7 +133,8 @@ See `docs/versioning.md`. walkthrough in `docs/tutorial-saas.md`; the SQLx persistence recipe in `docs/recipes/sqlx-persistence.md` shows the provider boundary without moving SQL into core. -- `examples/workout`: phone-first local-first product exemplar. Run with +- `examples/workout`: phone-first local-first product exemplar. Create a starter + with `cargo run -p hemx-xtask -- workout new PATH`; run the exemplar with `cargo run -p hemx-xtask -- workout dev` and open `http://127.0.0.1:3028`. It keeps workout truth as commands/events/projections and routes export through the host capability boundary; `examples/workout/README.md` documents diff --git a/examples/workout/README.md b/examples/workout/README.md index 89961c7..03d8784 100644 --- a/examples/workout/README.md +++ b/examples/workout/README.md @@ -7,6 +7,17 @@ final export shared or replayed, and invalid input, replay failure, or host deni recovered without storing DOM patches or UI update payloads as truth. req: examples/001 req: local/001 req: local/003 req: local/004 +## Create + +```sh +cargo run -p hemx-xtask -- workout new /tmp/my-workout-app +cargo check --manifest-path /tmp/my-workout-app/Cargo.toml +``` + +The created app is standalone: it points at local hemx crates, owns its +command/event/projection state, and keeps Android/iOS signing outside the repo. +req: examples/006 + ## Run ```sh diff --git a/hemx-xtask/src/main.rs b/hemx-xtask/src/main.rs index 0bc443c..3afc71c 100644 --- a/hemx-xtask/src/main.rs +++ b/hemx-xtask/src/main.rs @@ -9,7 +9,11 @@ fn main() -> ExitCode { match args.next().as_deref() { Some("test") | None => run_test_plan(), Some("bench") => run_bench_plan(), - Some("workout") => run_workout(args.next().as_deref()), + Some("workout") => { + let subcommand = args.next(); + let operand = args.next(); + run_workout(subcommand.as_deref(), operand.as_deref()) + } Some("workout-mobile") => run_workout_mobile(args.next().as_deref()), Some("help") | Some("--help") | Some("-h") => { print_help(); @@ -25,13 +29,14 @@ fn main() -> ExitCode { fn print_help() { println!( - "hemx-ci — resource-aware project checks\n\n cargo run -p hemx-xtask -- test\n cargo run -p hemx-xtask -- bench\n cargo run -p hemx-xtask -- workout dev\n cargo run -p hemx-xtask -- workout test\n cargo run -p hemx-xtask -- workout build\n cargo run -p hemx-xtask -- workout mobile-release\n cargo run -p hemx-xtask -- workout mobile-verify\n cargo run -p hemx-xtask -- workout doctor\n\nEnvironment overrides:\n HEMX_CI_JOBS=N compile jobs, capped by detected resources\n HEMX_CI_TEST_THREADS=N Rust test threads, capped by detected resources\n HEMX_CI_SKIP_BROWSER=1 skip browser E2E\n HEMX_WORKOUT_ORIGIN=https://app.example.com\n HEMX_WORKOUT_MOBILE_OUT=target/hemx-mobile/workout" + "hemx-ci — resource-aware project checks\n\n cargo run -p hemx-xtask -- test\n cargo run -p hemx-xtask -- bench\n cargo run -p hemx-xtask -- workout new PATH\n cargo run -p hemx-xtask -- workout dev\n cargo run -p hemx-xtask -- workout test\n cargo run -p hemx-xtask -- workout build\n cargo run -p hemx-xtask -- workout mobile-release\n cargo run -p hemx-xtask -- workout mobile-verify\n cargo run -p hemx-xtask -- workout doctor\n\nEnvironment overrides:\n HEMX_CI_JOBS=N compile jobs, capped by detected resources\n HEMX_CI_TEST_THREADS=N Rust test threads, capped by detected resources\n HEMX_CI_SKIP_BROWSER=1 skip browser E2E\n HEMX_WORKOUT_ORIGIN=https://app.example.com\n HEMX_WORKOUT_MOBILE_OUT=target/hemx-mobile/workout" ); } -fn run_workout(command: Option<&str>) -> ExitCode { +fn run_workout(command: Option<&str>, operand: Option<&str>) -> ExitCode { // req: examples/001 req: examples/006 match command.unwrap_or("dev") { + "new" | "create" => run_workout_new(operand), "dev" | "run" => Step::new( "workout-dev-server", ["run", "--bin", "hemx-workout-example"], @@ -64,6 +69,126 @@ fn run_workout(command: Option<&str>) -> ExitCode { } } +fn run_workout_new(destination: Option<&str>) -> ExitCode { + // req: examples/001 req: examples/006 + let Some(destination) = destination else { + eprintln!("usage: cargo run -p hemx-xtask -- workout new PATH"); + return ExitCode::from(2); + }; + let destination = PathBuf::from(destination); + if destination.exists() { + eprintln!( + "{} already exists; choose an empty path", + destination.display() + ); + return ExitCode::from(2); + } + match create_workout_app(&destination) { + Ok(()) => { + println!("workout-new\tpath={}", destination.display()); + println!( + "next\tcargo run --manifest-path {}/Cargo.toml --bin workout-app", + destination.display() + ); + ExitCode::SUCCESS + } + Err(err) => { + eprintln!( + "failed to create Workout app at {}: {err}", + destination.display() + ); + ExitCode::FAILURE + } + } +} + +fn create_workout_app(destination: &Path) -> std::io::Result<()> { + let root = repo_root()?; + copy_dir(&root.join("examples/workout"), destination)?; + let cargo_toml = destination.join("Cargo.toml"); + let manifest = fs::read_to_string(&cargo_toml)?; + fs::write( + &cargo_toml, + manifest + .replace( + "name = \"hemx-workout-example\"", + "name = \"hemx-workout-app\"", + ) + .replace( + "[[bin]]\nname = \"hemx-workout-app\"", + "[[bin]]\nname = \"workout-app\"", + ) + .replace("version.workspace = true", "version = \"0.1.0\"") + .replace("edition.workspace = true", "edition = \"2021\"") + .replace( + "path = \"../../../hemplate/hemplate\"", + &format!("path = \"{}\"", root.join("../hemplate/hemplate").display()), + ) + .replace( + "path = \"../../hemx\"", + &format!("path = \"{}\"", root.join("hemx").display()), + ) + .replace( + "path = \"../../hemx-axum\"", + &format!("path = \"{}\"", root.join("hemx-axum").display()), + ) + .replace( + "path = \"../../hemx-host\"", + &format!("path = \"{}\"", root.join("hemx-host").display()), + ) + .replace( + "path = \"../../hemx-test\"", + &format!("path = \"{}\"", root.join("hemx-test").display()), + ) + .replace( + "path = \"../../hemx-build\"", + &format!("path = \"{}\"", root.join("hemx-build").display()), + ) + .replace("hemx_workout_example", "hemx_workout_app"), + )?; + replace_in_file( + &destination.join("src/main.rs"), + "hemx_workout_example", + "hemx_workout_app", + )?; + fs::write( + destination.join("CREATED.md"), + "# Created Workout app\n\nRun locally with:\n\n```sh\ncargo run --manifest-path Cargo.toml --bin workout-app\n```\n\nBuild for production with:\n\n```sh\ncargo build --manifest-path Cargo.toml --release --bin workout-app\n```\n\nThis app owns command/event/projection state and keeps Android/iOS signing outside the repo. req: examples/006\n", + )?; + Ok(()) +} + +fn repo_root() -> std::io::Result { + let cwd = env::current_dir()?; + if cwd.join("examples/workout").exists() { + return Ok(cwd); + } + Ok(PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .unwrap_or_else(|| Path::new(".")) + .to_path_buf()) +} + +fn replace_in_file(path: &Path, from: &str, to: &str) -> std::io::Result<()> { + let contents = fs::read_to_string(path)?; + fs::write(path, contents.replace(from, to)) +} + +fn copy_dir(source: &Path, destination: &Path) -> std::io::Result<()> { + fs::create_dir_all(destination)?; + for entry in fs::read_dir(source)? { + let entry = entry?; + let source_path = entry.path(); + let destination_path = destination.join(entry.file_name()); + if source_path.is_dir() { + copy_dir(&source_path, &destination_path)?; + } else { + fs::copy(&source_path, &destination_path)?; + } + } + Ok(()) +} + fn run_workout_mobile(command: Option<&str>) -> ExitCode { match command.unwrap_or("release") { "release" => run_workout_mobile_release(), @@ -744,7 +869,7 @@ fn is_executable(path: impl AsRef) -> bool { #[cfg(test)] mod tests { use super::{ - android_twa_release_json, mobile_external_blockers, origin_host, + android_twa_release_json, create_workout_app, mobile_external_blockers, origin_host, verify_workout_mobile_release, workout_mobile_manifest, write_workout_mobile_release, Budget, WorkoutMobileConfig, }; @@ -782,6 +907,27 @@ mod tests { assert_eq!(super::bench_values(6), vec![1, 2, 4, 6]); } + #[test] + fn workout_new_creates_standalone_app_manifest() { + // req: examples/001 req: examples/006 + let destination = PathBuf::from("target/test-workout-new-app"); + let _ = fs::remove_dir_all(&destination); + + create_workout_app(&destination).expect("create app"); + let manifest = fs::read_to_string(destination.join("Cargo.toml")).expect("manifest"); + + assert!(manifest.contains("name = \"hemx-workout-app\"")); + assert!(manifest.contains("name = \"workout-app\"")); + assert!(manifest.contains("edition = \"2021\"")); + assert!(manifest.contains("hemx-build")); + let main_rs = fs::read_to_string(destination.join("src/main.rs")).expect("main rs"); + assert!(main_rs.contains("hemx_workout_app")); + assert!(!main_rs.contains("hemx_workout_example")); + assert!(destination.join("src/lib.rs").exists()); + assert!(destination.join("templates/workout.heml").exists()); + let _ = fs::remove_dir_all(&destination); + } + #[test] fn workout_mobile_manifest_names_production_boundaries() { // req: examples/001 req: local/001 req: host/002