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
This commit is contained in:
@@ -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.
|
- 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.
|
- 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.
|
- 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.
|
- 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).
|
- 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.
|
- JS runtime changes must preserve root-scoped lookup and avoid selectors, VDOM, expressions, and per-node listeners.
|
||||||
|
|||||||
@@ -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
|
hemx must not vendor providers or add framework-specific magic. See
|
||||||
`docs/recipes/observability-flags.md`, `docs/recipes/deploy-versioning.md`,
|
`docs/recipes/observability-flags.md`, `docs/recipes/deploy-versioning.md`,
|
||||||
and `docs/recipes/mobile-release.md`. req: examples/006
|
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,
|
- **PWA/offline/sync:** optional adapters may reuse generated targets/effects,
|
||||||
but core hemx must not gain a mandatory client state graph or local app
|
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
|
runtime. Local truth is commands/events/projections, not stored DOM patches or
|
||||||
|
|||||||
@@ -133,6 +133,9 @@ client app state framework.
|
|||||||
### req: ceremony/005
|
### 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]
|
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
|
## progressive_disclosure
|
||||||
|
|||||||
@@ -7,6 +7,19 @@ vendor work. req: examples/006
|
|||||||
|
|
||||||
## Command surface
|
## 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
|
```sh
|
||||||
cargo run -p hemx-xtask -- workout dev
|
cargo run -p hemx-xtask -- workout dev
|
||||||
cargo run -p hemx-xtask -- workout test
|
cargo run -p hemx-xtask -- workout test
|
||||||
|
|||||||
+76
-15
@@ -16,8 +16,8 @@ fn main() -> ExitCode {
|
|||||||
}
|
}
|
||||||
Some("app") => {
|
Some("app") => {
|
||||||
let subcommand = args.next();
|
let subcommand = args.next();
|
||||||
let operand = args.next();
|
let operands = args.collect::<Vec<_>>();
|
||||||
run_app(subcommand.as_deref(), operand.as_deref())
|
run_app(subcommand.as_deref(), &operands)
|
||||||
}
|
}
|
||||||
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") => {
|
||||||
@@ -34,14 +34,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 -- 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 {
|
fn run_app(command: Option<&str>, operands: &[String]) -> ExitCode {
|
||||||
// req: ceremony/005 req: canonical_authoring/003
|
// req: ceremony/005 req: ceremony/006 req: canonical_authoring/003
|
||||||
match command.unwrap_or("help") {
|
match command.unwrap_or("help") {
|
||||||
"new" | "create" => run_app_new(operand),
|
"new" | "create" => run_app_new(operands),
|
||||||
"help" | "--help" | "-h" => {
|
"help" | "--help" | "-h" => {
|
||||||
print_help();
|
print_help();
|
||||||
ExitCode::SUCCESS
|
ExitCode::SUCCESS
|
||||||
@@ -54,11 +54,16 @@ fn run_app(command: Option<&str>, operand: Option<&str>) -> ExitCode {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn run_app_new(destination: Option<&str>) -> ExitCode {
|
fn run_app_new(operands: &[String]) -> ExitCode {
|
||||||
// req: ceremony/005 req: canonical_authoring/003
|
// req: ceremony/005 req: ceremony/006 req: canonical_authoring/003
|
||||||
let Some(destination) = destination else {
|
let (mobile, destination) = match operands {
|
||||||
eprintln!("usage: cargo run -p hemx-xtask -- app new PATH");
|
[destination] => (false, destination.as_str()),
|
||||||
return ExitCode::from(2);
|
[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);
|
let destination = PathBuf::from(destination);
|
||||||
if destination.exists() {
|
if destination.exists() {
|
||||||
@@ -68,13 +73,28 @@ fn run_app_new(destination: Option<&str>) -> ExitCode {
|
|||||||
);
|
);
|
||||||
return ExitCode::from(2);
|
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(()) => {
|
Ok(()) => {
|
||||||
println!("app-new\tpath={}", destination.display());
|
println!(
|
||||||
|
"{}\tpath={}",
|
||||||
|
if mobile { "app-new-mobile" } else { "app-new" },
|
||||||
|
destination.display()
|
||||||
|
);
|
||||||
println!(
|
println!(
|
||||||
"next\tcargo test --manifest-path {}/Cargo.toml",
|
"next\tcargo test --manifest-path {}/Cargo.toml",
|
||||||
destination.display()
|
destination.display()
|
||||||
);
|
);
|
||||||
|
if mobile {
|
||||||
|
println!(
|
||||||
|
"next\tHEMX_WORKOUT_ORIGIN=https://app.example.com {}/hemx-workout mobile-release",
|
||||||
|
destination.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
ExitCode::SUCCESS
|
ExitCode::SUCCESS
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
@@ -194,6 +214,16 @@ fn create_app_scaffold(destination: &Path) -> std::io::Result<()> {
|
|||||||
Ok(())
|
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<()> {
|
fn create_workout_app(destination: &Path) -> std::io::Result<()> {
|
||||||
let root = repo_root()?;
|
let root = repo_root()?;
|
||||||
copy_dir(&root.join("examples/workout"), destination)?;
|
copy_dir(&root.join("examples/workout"), destination)?;
|
||||||
@@ -1145,8 +1175,8 @@ fn is_executable(path: impl AsRef<Path>) -> bool {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
android_twa_release_json, create_app_scaffold, create_workout_app,
|
android_twa_release_json, create_app_scaffold, create_mobile_app_scaffold,
|
||||||
mobile_external_blockers, origin_host, verify_workout_mobile_release,
|
create_workout_app, mobile_external_blockers, origin_host, verify_workout_mobile_release,
|
||||||
workout_mobile_manifest, write_workout_mobile_release, Budget, WorkoutMobileConfig,
|
workout_mobile_manifest, write_workout_mobile_release, Budget, WorkoutMobileConfig,
|
||||||
};
|
};
|
||||||
use std::fs;
|
use std::fs;
|
||||||
@@ -1211,6 +1241,37 @@ mod tests {
|
|||||||
let _ = fs::remove_dir_all(&destination);
|
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("<form"));
|
||||||
|
assert!(template.contains("data-hemx-slot"));
|
||||||
|
assert!(destination.join("tests/e2e.rs").exists());
|
||||||
|
let _ = fs::remove_dir_all(&destination);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn workout_new_creates_standalone_app_manifest() {
|
fn workout_new_creates_standalone_app_manifest() {
|
||||||
// req: examples/001 req: examples/006
|
// req: examples/001 req: examples/006
|
||||||
|
|||||||
Reference in New Issue
Block a user