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
This commit is contained in:
slhx agent
2026-06-12 12:46:06 +02:00
parent d4e99b6e73
commit f8f1a9aee5
3 changed files with 163 additions and 5 deletions
+2 -1
View File
@@ -133,7 +133,8 @@ See `docs/versioning.md`.
walkthrough in `docs/tutorial-saas.md`; the SQLx persistence recipe in walkthrough in `docs/tutorial-saas.md`; the SQLx persistence recipe in
`docs/recipes/sqlx-persistence.md` shows the provider boundary without moving `docs/recipes/sqlx-persistence.md` shows the provider boundary without moving
SQL into core. 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`. `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 It keeps workout truth as commands/events/projections and routes export
through the host capability boundary; `examples/workout/README.md` documents through the host capability boundary; `examples/workout/README.md` documents
+11
View File
@@ -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. recovered without storing DOM patches or UI update payloads as truth.
req: examples/001 req: local/001 req: local/003 req: local/004 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 ## Run
```sh ```sh
+150 -4
View File
@@ -9,7 +9,11 @@ fn main() -> ExitCode {
match args.next().as_deref() { match args.next().as_deref() {
Some("test") | None => run_test_plan(), Some("test") | None => run_test_plan(),
Some("bench") => run_bench_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("workout-mobile") => run_workout_mobile(args.next().as_deref()),
Some("help") | Some("--help") | Some("-h") => { Some("help") | Some("--help") | Some("-h") => {
print_help(); print_help();
@@ -25,13 +29,14 @@ fn main() -> ExitCode {
fn print_help() { fn print_help() {
println!( 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 // req: examples/001 req: examples/006
match command.unwrap_or("dev") { match command.unwrap_or("dev") {
"new" | "create" => run_workout_new(operand),
"dev" | "run" => Step::new( "dev" | "run" => Step::new(
"workout-dev-server", "workout-dev-server",
["run", "--bin", "hemx-workout-example"], ["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<PathBuf> {
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 { fn run_workout_mobile(command: Option<&str>) -> ExitCode {
match command.unwrap_or("release") { match command.unwrap_or("release") {
"release" => run_workout_mobile_release(), "release" => run_workout_mobile_release(),
@@ -744,7 +869,7 @@ fn is_executable(path: impl AsRef<Path>) -> bool {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{ 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, verify_workout_mobile_release, workout_mobile_manifest, write_workout_mobile_release,
Budget, WorkoutMobileConfig, Budget, WorkoutMobileConfig,
}; };
@@ -782,6 +907,27 @@ mod tests {
assert_eq!(super::bench_values(6), vec![1, 2, 4, 6]); 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] #[test]
fn workout_mobile_manifest_names_production_boundaries() { fn workout_mobile_manifest_names_production_boundaries() {
// req: examples/001 req: local/001 req: host/002 // req: examples/001 req: local/001 req: host/002