From d4916b1a1e7a6122baa67ee557b6f7c45941ed01 Mon Sep 17 00:00:00 2001 From: slhx agent Date: Fri, 12 Jun 2026 15:34:32 +0200 Subject: [PATCH] feat(xtask): add mobile app starter Add app new --mobile as the public phone-first starter path. It reuses the Workout starter so created apps carry a real page/form/keyed partial/notice flow, typed host capability handling, app-owned recovery truth, and inspectable mobile release/verify commands without adding a mobile framework. req: ceremony/006 req: examples/006 --- AGENTS.md | 2 +- README.md | 5 ++ REQUIREMENTS.md | 3 ++ docs/recipes/mobile-release.md | 13 +++++ hemx-xtask/src/main.rs | 91 ++++++++++++++++++++++++++++------ 5 files changed, 98 insertions(+), 16 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 75c3cef..47927a0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -54,7 +54,7 @@ Keep it stable. Prefer pointers to canonical sources over copied structure, file - hemx core stays small: effects, typed ids, registries, and wire schema only. - Routing, auth, sessions, transport, transitions, sync, and storage belong in integration/user crates. - Public examples and beginner APIs should use generated resources and `IntoEffect`, not raw ids or runtime opcodes. -- Use `cargo run -p hemx-xtask -- app new PATH` for the generic page/form/keyed-row/notice starter; use `workout new PATH` only for the phone-first Workout product starter. req: ceremony/005 +- Use `cargo run -p hemx-xtask -- app new PATH` for the generic page/form/keyed-row/notice starter, and `cargo run -p hemx-xtask -- app new --mobile PATH` for the phone-first starter with host capabilities, recovery truth, and release-kit commands. req: ceremony/005 req: ceremony/006 - The public component-reuse explanation lives in `docs/recipes/reusable-partials.md`; do not grow a client component framework to explain partial composition. - Hemlate examples must use real hemplate syntax, not Vue/Handlebars sketches: `{+ expr +}` for escaped text, `{+= expr =+}` only for trusted/rendered HTML, `+attr="expr"` for dynamic attributes, and Rust-shaped `h-if`, `h-for`, `h-match`, `h-case` directives (`h-case="_"` is the default arm). - JS runtime changes must preserve root-scoped lookup and avoid selectors, VDOM, expressions, and per-node listeners. diff --git a/README.md b/README.md index f0892e8..d992f0e 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,11 @@ and integrate at explicit boundaries. req: laws/002 req: auth/001 hemx must not vendor providers or add framework-specific magic. See `docs/recipes/observability-flags.md`, `docs/recipes/deploy-versioning.md`, and `docs/recipes/mobile-release.md`. req: examples/006 +- **Mobile starter:** create the phone-first path with `cargo run -p + hemx-xtask -- app new --mobile PATH`. The starter carries a real app flow, + typed host capabilities, command/event/projection recovery truth, and + inspectable mobile release-kit commands without adding a native UI framework. + req: ceremony/006 - **PWA/offline/sync:** optional adapters may reuse generated targets/effects, but core hemx must not gain a mandatory client state graph or local app runtime. Local truth is commands/events/projections, not stored DOM patches or diff --git a/REQUIREMENTS.md b/REQUIREMENTS.md index d58af0b..703fca5 100644 --- a/REQUIREMENTS.md +++ b/REQUIREMENTS.md @@ -133,6 +133,9 @@ client app state framework. ### req: ceremony/005 005 `cargo run -p hemx-xtask -- app new PATH` creates a generic checked-hypermedia scaffold with a page, form, keyed row partial, notice slot, Rust handlers, and tests using generated helpers instead of raw ids, opcodes, selector UI JavaScript, or manual registry plumbing. [north_star] +### req: ceremony/006 +006 `cargo run -p hemx-xtask -- app new --mobile PATH` creates a phone-first starter with a real page/form/keyed partial/notice flow, typed host capability round trip, app-owned command/event/projection recovery truth, and inspectable mobile release/verify metadata without adding a hemx mobile framework, client store, signing-secret owner, or store submission bot. [north_star] + --- ## progressive_disclosure diff --git a/docs/recipes/mobile-release.md b/docs/recipes/mobile-release.md index 51fb6c2..1b675e0 100644 --- a/docs/recipes/mobile-release.md +++ b/docs/recipes/mobile-release.md @@ -7,6 +7,19 @@ vendor work. req: examples/006 ## Command surface +Create a standalone phone-first starter from the public app command when you want +this path outside the repository: + +```sh +cargo run -p hemx-xtask -- app new --mobile PATH +``` + +The created app includes the same `hemx-workout mobile-release` and +`hemx-workout mobile-verify` commands, plus `MOBILE_STARTER.md` naming the host, +recovery, and release-kit boundary. req: ceremony/006 + +For the in-repository exemplar: + ```sh cargo run -p hemx-xtask -- workout dev cargo run -p hemx-xtask -- workout test diff --git a/hemx-xtask/src/main.rs b/hemx-xtask/src/main.rs index b6db791..3c6ead9 100644 --- a/hemx-xtask/src/main.rs +++ b/hemx-xtask/src/main.rs @@ -16,8 +16,8 @@ fn main() -> ExitCode { } Some("app") => { let subcommand = args.next(); - let operand = args.next(); - run_app(subcommand.as_deref(), operand.as_deref()) + let operands = args.collect::>(); + run_app(subcommand.as_deref(), &operands) } Some("workout-mobile") => run_workout_mobile(args.next().as_deref()), Some("help") | Some("--help") | Some("-h") => { @@ -34,14 +34,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 -- app new PATH\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" + "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 -- app new PATH\n cargo run -p hemx-xtask -- app new --mobile PATH\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_app(command: Option<&str>, operand: Option<&str>) -> ExitCode { - // req: ceremony/005 req: canonical_authoring/003 +fn run_app(command: Option<&str>, operands: &[String]) -> ExitCode { + // req: ceremony/005 req: ceremony/006 req: canonical_authoring/003 match command.unwrap_or("help") { - "new" | "create" => run_app_new(operand), + "new" | "create" => run_app_new(operands), "help" | "--help" | "-h" => { print_help(); ExitCode::SUCCESS @@ -54,11 +54,16 @@ fn run_app(command: Option<&str>, operand: Option<&str>) -> ExitCode { } } -fn run_app_new(destination: Option<&str>) -> ExitCode { - // req: ceremony/005 req: canonical_authoring/003 - let Some(destination) = destination else { - eprintln!("usage: cargo run -p hemx-xtask -- app new PATH"); - return ExitCode::from(2); +fn run_app_new(operands: &[String]) -> ExitCode { + // req: ceremony/005 req: ceremony/006 req: canonical_authoring/003 + let (mobile, destination) = match operands { + [destination] => (false, destination.as_str()), + [flag, destination] if flag == "--mobile" => (true, destination.as_str()), + [destination, flag] if flag == "--mobile" => (true, destination.as_str()), + _ => { + eprintln!("usage: cargo run -p hemx-xtask -- app new [--mobile] PATH"); + return ExitCode::from(2); + } }; let destination = PathBuf::from(destination); if destination.exists() { @@ -68,13 +73,28 @@ fn run_app_new(destination: Option<&str>) -> ExitCode { ); return ExitCode::from(2); } - match create_app_scaffold(&destination) { + let result = if mobile { + create_mobile_app_scaffold(&destination) + } else { + create_app_scaffold(&destination) + }; + match result { Ok(()) => { - println!("app-new\tpath={}", destination.display()); + println!( + "{}\tpath={}", + if mobile { "app-new-mobile" } else { "app-new" }, + destination.display() + ); println!( "next\tcargo test --manifest-path {}/Cargo.toml", destination.display() ); + if mobile { + println!( + "next\tHEMX_WORKOUT_ORIGIN=https://app.example.com {}/hemx-workout mobile-release", + destination.display() + ); + } ExitCode::SUCCESS } Err(err) => { @@ -194,6 +214,16 @@ fn create_app_scaffold(destination: &Path) -> std::io::Result<()> { Ok(()) } +fn create_mobile_app_scaffold(destination: &Path) -> std::io::Result<()> { + // req: ceremony/006 req: examples/006 + create_workout_app(destination)?; + fs::write( + destination.join("MOBILE_STARTER.md"), + "# Mobile hemx starter\n\nThis starter is the phone-first Workout path exposed through `app new --mobile`: it has a real page/form/keyed partial/notice flow, typed host haptics/share calls returning through Rust handlers, app-owned command/event/projection recovery truth, and the same `hemx-workout mobile-release` / `hemx-workout mobile-verify` release-kit commands.\n\nIt deliberately does not add a hemx mobile framework, client store, signing secret owner, or store submission bot. req: ceremony/006 req: examples/006\n", + )?; + Ok(()) +} + fn create_workout_app(destination: &Path) -> std::io::Result<()> { let root = repo_root()?; copy_dir(&root.join("examples/workout"), destination)?; @@ -1145,8 +1175,8 @@ fn is_executable(path: impl AsRef) -> bool { #[cfg(test)] mod tests { use super::{ - android_twa_release_json, create_app_scaffold, create_workout_app, - mobile_external_blockers, origin_host, verify_workout_mobile_release, + android_twa_release_json, create_app_scaffold, create_mobile_app_scaffold, + create_workout_app, mobile_external_blockers, origin_host, verify_workout_mobile_release, workout_mobile_manifest, write_workout_mobile_release, Budget, WorkoutMobileConfig, }; use std::fs; @@ -1211,6 +1241,37 @@ mod tests { let _ = fs::remove_dir_all(&destination); } + #[test] + fn app_new_mobile_creates_phone_first_release_starter() { + // req: ceremony/006 req: examples/006 + let destination = PathBuf::from("target/test-hemx-app-new-mobile"); + let _ = fs::remove_dir_all(&destination); + + create_mobile_app_scaffold(&destination).expect("create mobile app scaffold"); + let mobile_readme = + fs::read_to_string(destination.join("MOBILE_STARTER.md")).expect("mobile docs"); + let command = fs::read_to_string(destination.join("hemx-workout")).expect("command"); + let lib_rs = fs::read_to_string(destination.join("src/lib.rs")).expect("lib rs"); + let template = + fs::read_to_string(destination.join("templates/workout.heml")).expect("template"); + + assert!(mobile_readme.contains("phone-first Workout path")); + assert!(mobile_readme.contains("typed host haptics/share calls")); + assert!(mobile_readme.contains("command/event/projection recovery truth")); + assert!(mobile_readme.contains("does not add a hemx mobile framework")); + assert!(command.contains("mobile-release")); + assert!(command.contains("mobile-verify")); + assert!(command.contains("app-owned command/event/projection records")); + assert!(command.contains("external_blockers")); + assert!(lib_rs.contains("HostCall::Share")); + assert!(lib_rs.contains("WorkoutCommand")); + assert!(lib_rs.contains("WorkoutEvent")); + assert!(template.contains("