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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+150
-4
@@ -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<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 {
|
||||
match command.unwrap_or("release") {
|
||||
"release" => run_workout_mobile_release(),
|
||||
@@ -744,7 +869,7 @@ fn is_executable(path: impl AsRef<Path>) -> 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
|
||||
|
||||
Reference in New Issue
Block a user