feat(workout): give created apps their release command

Write a standalone hemx-workout command into created Workout apps so new app owners keep the same dev, test, build, mobile-release, mobile-verify, and doctor surface outside the monorepo.

req: examples/006
This commit is contained in:
slhx agent
2026-06-12 13:23:57 +02:00
parent a4a4dcf74f
commit 3f0cf0a7cb
+120 -1
View File
@@ -151,9 +151,10 @@ fn create_workout_app(destination: &Path) -> std::io::Result<()> {
"hemx_workout_example", "hemx_workout_example",
"hemx_workout_app", "hemx_workout_app",
)?; )?;
write_workout_app_command(destination)?;
fs::write( fs::write(
destination.join("CREATED.md"), 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", "# Created Workout app\n\nUse one command surface from this directory:\n\n```sh\n./hemx-workout dev\n./hemx-workout test\n./hemx-workout build\nHEMX_WORKOUT_ORIGIN=https://workout.example.com ./hemx-workout mobile-release\nHEMX_WORKOUT_ORIGIN=https://workout.example.com ./hemx-workout mobile-verify\n./hemx-workout doctor\n```\n\nThis app owns command/event/projection state and keeps Android/iOS SDKs, store submission targets, and signing outside the repo. req: examples/006\n",
)?; )?;
Ok(()) Ok(())
} }
@@ -174,6 +175,119 @@ fn replace_in_file(path: &Path, from: &str, to: &str) -> std::io::Result<()> {
fs::write(path, contents.replace(from, to)) fs::write(path, contents.replace(from, to))
} }
fn write_workout_app_command(destination: &Path) -> std::io::Result<()> {
let script_path = destination.join("hemx-workout");
fs::write(&script_path, workout_app_command_script())?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut permissions = fs::metadata(&script_path)?.permissions();
permissions.set_mode(0o755);
fs::set_permissions(&script_path, permissions)?;
}
Ok(())
}
fn workout_app_command_script() -> String {
format!(
r#"#!/usr/bin/env sh
set -eu
APP_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
CMD=${{1:-dev}}
OUT=${{HEMX_WORKOUT_MOBILE_OUT:-"$APP_DIR/target/hemx-mobile/workout"}}
ORIGIN=${{HEMX_WORKOUT_ORIGIN:-https://workout.example.invalid}}
APP_ID=${{HEMX_WORKOUT_APP_ID:-com.hemx.workout}}
APP_NAME=${{HEMX_WORKOUT_APP_NAME:-hemx Workout Copilot}}
VERSION=${{HEMX_WORKOUT_VERSION:-0.1.0}}
RUNTIME_PATH='{runtime_path}'
RUNTIME_SHA='{runtime_sha}'
write_blockers() {{
mkdir -p "$OUT"
{{
echo '# Workout mobile external blockers'
echo
echo 'The release kit is generated, but these external inputs are still required for signed store artifacts:'
echo
[ -n "${{ANDROID_HOME:-${{ANDROID_SDK_ROOT:-}}}}" ] || echo '- Android SDK not found: set ANDROID_HOME or ANDROID_SDK_ROOT before producing signed Android artifacts'
command -v java >/dev/null 2>&1 || echo '- Java runtime not found: Android packaging requires a JDK'
[ -n "${{HEMX_WORKOUT_ANDROID_KEYSTORE:-}}" ] || echo '- Android signing key not configured: set HEMX_WORKOUT_ANDROID_KEYSTORE for store-ready signing'
[ -n "${{HEMX_WORKOUT_GOOGLE_PLAY_TRACK:-}}" ] || echo '- Google Play submission target not configured: set HEMX_WORKOUT_GOOGLE_PLAY_TRACK after choosing the Play Console track outside this app'
command -v xcodebuild >/dev/null 2>&1 || echo '- Xcode command line tools not found: iOS archive/export requires xcodebuild on macOS'
[ -n "${{HEMX_WORKOUT_IOS_TEAM_ID:-}}" ] || echo '- iOS signing team not configured: set HEMX_WORKOUT_IOS_TEAM_ID for App Store/TestFlight export'
[ -n "${{HEMX_WORKOUT_APP_STORE_CONNECT_TEAM:-}}" ] || echo '- App Store Connect submission team not configured: set HEMX_WORKOUT_APP_STORE_CONNECT_TEAM after choosing the Apple account outside this app'
}} > "$OUT/BLOCKERS.md"
}}
case "$CMD" in
dev|run)
exec cargo run --manifest-path "$APP_DIR/Cargo.toml" --bin workout-app
;;
test)
exec cargo test --manifest-path "$APP_DIR/Cargo.toml"
;;
build)
exec cargo build --manifest-path "$APP_DIR/Cargo.toml" --release --bin workout-app
;;
mobile-release)
cargo build --manifest-path "$APP_DIR/Cargo.toml" --release --bin workout-app
mkdir -p "$OUT/android" "$OUT/ios"
write_blockers
printf 'path\talgorithm\tdigest\n%s\tsha256\t%s\n' "$RUNTIME_PATH" "$RUNTIME_SHA" > "$OUT/asset-integrity.tsv"
cat > "$OUT/release-manifest.json" <<EOF
{{
"app_id": "$APP_ID",
"name": "$APP_NAME",
"version": "$VERSION",
"origin": "$ORIGIN",
"server_binary": "$APP_DIR/target/release/workout-app",
"runtime_asset_path": "$RUNTIME_PATH",
"runtime_asset_sha256": "$RUNTIME_SHA",
"asset_integrity": "asset-integrity.tsv",
"cache_policy": "cache only release-scoped HTML/CSS/runtime assets; never store DOM patches or UI effects as truth",
"state_policy": "app-owned command/event/projection records",
"host_result_kinds": ["denied", "timeout", "unavailable", "error"],
"environment_boundary": "public mobile shell config lives here; secrets and signing credentials stay outside the app repo",
"rollback": "redeploy the previous server binary and matching mobile shell metadata; rebuild store artifacts with the previous version/signing inputs",
"android": "android/twa-release.json",
"ios": "ios/webview-release.json"
}}
EOF
cat > "$OUT/android/twa-release.json" <<EOF
{{"package":"$APP_ID","name":"$APP_NAME","start_url":"$ORIGIN/","host_capabilities":["share","haptics"],"host_result_kinds":["denied","timeout","unavailable","error"],"signing":"external Android keystore; never commit credentials"}}
EOF
cat > "$OUT/ios/webview-release.json" <<EOF
{{"bundle_id":"$APP_ID","name":"$APP_NAME","start_url":"$ORIGIN/","host_capabilities":["share","haptics"],"host_result_kinds":["denied","timeout","unavailable","error"],"signing":"external Apple team/provisioning profile; never commit credentials"}}
EOF
echo "workout-mobile\tout=$OUT"
;;
mobile-verify|verify)
"$0" test
test -f "$APP_DIR/target/release/workout-app" || {{ echo 'missing target/release/workout-app; run mobile-release first' >&2; exit 1; }}
case "$ORIGIN" in https://*) ;; *) echo 'HEMX_WORKOUT_ORIGIN must be HTTPS' >&2; exit 1;; esac
for f in release-manifest.json asset-integrity.tsv android/twa-release.json ios/webview-release.json BLOCKERS.md; do test -f "$OUT/$f" || {{ echo "missing $OUT/$f" >&2; exit 1; }}; done
grep -F "$RUNTIME_PATH" "$OUT/release-manifest.json" >/dev/null
grep -F "$RUNTIME_SHA" "$OUT/asset-integrity.tsv" >/dev/null
grep -F 'app-owned command/event/projection records' "$OUT/release-manifest.json" >/dev/null
grep -F 'host_result_kinds' "$OUT/android/twa-release.json" >/dev/null
grep -F 'host_result_kinds' "$OUT/ios/webview-release.json" >/dev/null
echo "workout-mobile-verified\tout=$OUT"
;;
doctor)
write_blockers
cat "$OUT/BLOCKERS.md"
;;
*)
echo 'usage: ./hemx-workout dev|test|build|mobile-release|mobile-verify|doctor' >&2
exit 2
;;
esac
"#,
runtime_path = hemx_js::RUNTIME_JS_PATH,
runtime_sha = hemx_js::RUNTIME_JS_HASH,
)
}
fn copy_dir(source: &Path, destination: &Path) -> std::io::Result<()> { fn copy_dir(source: &Path, destination: &Path) -> std::io::Result<()> {
fs::create_dir_all(destination)?; fs::create_dir_all(destination)?;
for entry in fs::read_dir(source)? { for entry in fs::read_dir(source)? {
@@ -958,6 +1072,11 @@ mod tests {
let main_rs = fs::read_to_string(destination.join("src/main.rs")).expect("main rs"); 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_app"));
assert!(!main_rs.contains("hemx_workout_example")); assert!(!main_rs.contains("hemx_workout_example"));
let command = fs::read_to_string(destination.join("hemx-workout")).expect("command");
assert!(command.contains("mobile-release"));
assert!(command.contains("mobile-verify"));
assert!(command.contains(hemx_js::RUNTIME_JS_HASH));
assert!(command.contains("app-owned command/event/projection records"));
assert!(destination.join("src/lib.rs").exists()); assert!(destination.join("src/lib.rs").exists());
assert!(destination.join("templates/workout.heml").exists()); assert!(destination.join("templates/workout.heml").exists());
let _ = fs::remove_dir_all(&destination); let _ = fs::remove_dir_all(&destination);