fix(xtask): polish generated app next steps

Give app new users app-local next steps and generated README/CREATED docs, rename visible v0 text, and fail early when the required hemplate checkout is missing instead of producing a broken path dependency.

req: ceremony/005
This commit is contained in:
slhx agent
2026-06-12 17:26:23 +02:00
parent c71aa63cec
commit 0f1673834b
2 changed files with 45 additions and 11 deletions
+5
View File
@@ -10,6 +10,11 @@ SaaS tutorial app, an advanced Kanban milestone sketch, and a full techdemo.
The v1 story now has tutorial, recipe, diagnostics, and stability docs; see The v1 story now has tutorial, recipe, diagnostics, and stability docs; see
`docs/v1-readiness.md` for the remaining close-gap audit. `docs/v1-readiness.md` for the remaining close-gap audit.
Local checkout note: until the hemplate crates are published, this repository
expects `hemplate` checked out next to `hemx` as `../hemplate/hemplate`. The app
scaffolder fails with that exact path if the prerequisite is missing, instead of
creating an app that fails later with a vague Cargo path-dependency error.
## The normal path ## The normal path
For beginner and production-shaped app code, stay on this path. req: public_api/001 req: public_api/005 For beginner and production-shaped app code, stay on this path. req: public_api/001 req: public_api/005
+40 -11
View File
@@ -85,15 +85,16 @@ fn run_app_new(operands: &[String]) -> ExitCode {
if mobile { "app-new-mobile" } else { "app-new" }, if mobile { "app-new-mobile" } else { "app-new" },
destination.display() destination.display()
); );
println!( println!("next\tcd {}", destination.display());
"next\tcargo test --manifest-path {}/Cargo.toml",
destination.display()
);
if mobile { if mobile {
println!( println!("next\t./hemx-app test");
"next\tHEMX_APP_ORIGIN=https://app.example.com {}/hemx-app mobile-release", println!("next\t./hemx-app build");
destination.display() println!("next\tHEMX_APP_ORIGIN=https://app.example.com ./hemx-app mobile-release");
); println!("next\tHEMX_APP_ORIGIN=https://app.example.com ./hemx-app mobile-verify");
} else {
println!("next\tcargo test");
println!("next\tcargo run");
println!("next\tcargo build --release");
} }
ExitCode::SUCCESS ExitCode::SUCCESS
} }
@@ -175,6 +176,7 @@ fn run_workout_new(destination: Option<&str>) -> ExitCode {
fn create_app_scaffold(destination: &Path) -> std::io::Result<()> { fn create_app_scaffold(destination: &Path) -> std::io::Result<()> {
let root = repo_root()?; let root = repo_root()?;
let hemplate = hemplate_checkout(&root)?;
copy_dir(&root.join("examples/v0"), destination)?; copy_dir(&root.join("examples/v0"), destination)?;
let cargo_toml = destination.join("Cargo.toml"); let cargo_toml = destination.join("Cargo.toml");
let manifest = fs::read_to_string(&cargo_toml)?; let manifest = fs::read_to_string(&cargo_toml)?;
@@ -186,7 +188,7 @@ fn create_app_scaffold(destination: &Path) -> std::io::Result<()> {
.replace("edition.workspace = true", "edition = \"2021\"") .replace("edition.workspace = true", "edition = \"2021\"")
.replace( .replace(
"path = \"../../../hemplate/hemplate\"", "path = \"../../../hemplate/hemplate\"",
&format!("path = \"{}\"", root.join("../hemplate/hemplate").display()), &format!("path = \"{}\"", hemplate.display()),
) )
.replace( .replace(
"path = \"../../hemx\"", "path = \"../../hemx\"",
@@ -207,9 +209,14 @@ fn create_app_scaffold(destination: &Path) -> std::io::Result<()> {
)?; )?;
replace_in_tree(destination, "hemx_v0_examples", "hemx_app")?; replace_in_tree(destination, "hemx_v0_examples", "hemx_app")?;
replace_in_tree(destination, "hemx-v0-examples", "hemx-app")?; replace_in_tree(destination, "hemx-v0-examples", "hemx-app")?;
replace_in_tree(destination, "hemx v0 browser examples", "hemx app")?;
fs::write(
destination.join("README.md"),
"# hemx app\n\nThis app was created by `cargo run -p hemx-xtask -- app new PATH`. It is the generic checked-hypermedia starter: one 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. req: ceremony/005\n\n## Run\n\n```sh\ncargo run\n```\n\nOpen <http://127.0.0.1:3000>.\n\n## Test and build\n\n```sh\ncargo test\ncargo build --release\n```\n\n## Edit\n\n- `templates/todos.heml` owns the reusable partials and generated target names.\n- `src/main.rs` owns handlers and app state.\n- `src/lib.rs` re-exports generated `ui` helpers.\n\nThe reusable todo row partial is rendered in the initial page and updated through generated append/replace/remove/dynamic-batch effects. req: canonical_authoring/003\n",
)?;
fs::write( fs::write(
destination.join("CREATED.md"), destination.join("CREATED.md"),
"# Created hemx app\n\nThis scaffold is the generic checked-hypermedia starting point: one 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.\n\n```sh\ncargo test\ncargo run\n```\n\nThe reusable todo row partial is rendered in the initial page and updated through generated append/replace/remove/dynamic-batch effects. req: canonical_authoring/003 req: ceremony/005\n", "# Created hemx app\n\nUse one app-owned command surface from this directory:\n\n```sh\ncargo test\ncargo run\ncargo build --release\n```\n\nThis scaffold is the generic checked-hypermedia starting point: one 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.\n\nThe reusable todo row partial is rendered in the initial page and updated through generated append/replace/remove/dynamic-batch effects. req: canonical_authoring/003 req: ceremony/005\n",
)?; )?;
Ok(()) Ok(())
} }
@@ -256,6 +263,7 @@ fn create_mobile_app_scaffold(destination: &Path) -> std::io::Result<()> {
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()?;
let hemplate = hemplate_checkout(&root)?;
copy_dir(&root.join("examples/workout"), destination)?; copy_dir(&root.join("examples/workout"), destination)?;
let cargo_toml = destination.join("Cargo.toml"); let cargo_toml = destination.join("Cargo.toml");
let manifest = fs::read_to_string(&cargo_toml)?; let manifest = fs::read_to_string(&cargo_toml)?;
@@ -274,7 +282,7 @@ fn create_workout_app(destination: &Path) -> std::io::Result<()> {
.replace("edition.workspace = true", "edition = \"2021\"") .replace("edition.workspace = true", "edition = \"2021\"")
.replace( .replace(
"path = \"../../../hemplate/hemplate\"", "path = \"../../../hemplate/hemplate\"",
&format!("path = \"{}\"", root.join("../hemplate/hemplate").display()), &format!("path = \"{}\"", hemplate.display()),
) )
.replace( .replace(
"path = \"../../hemx\"", "path = \"../../hemx\"",
@@ -318,6 +326,21 @@ fn repo_root() -> std::io::Result<PathBuf> {
.to_path_buf()) .to_path_buf())
} }
fn hemplate_checkout(root: &Path) -> std::io::Result<PathBuf> {
let hemplate = root.join("../hemplate/hemplate");
if hemplate.join("Cargo.toml").exists() {
Ok(hemplate)
} else {
Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
format!(
"hemplate checkout not found at {}; clone hemplate next to hemx or adjust the generated Cargo.toml after creation",
hemplate.display()
),
))
}
}
fn replace_in_file(path: &Path, from: &str, to: &str) -> std::io::Result<()> { fn replace_in_file(path: &Path, from: &str, to: &str) -> std::io::Result<()> {
let contents = fs::read_to_string(path)?; let contents = fs::read_to_string(path)?;
fs::write(path, contents.replace(from, to)) fs::write(path, contents.replace(from, to))
@@ -1254,6 +1277,7 @@ 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");
let template = let template =
fs::read_to_string(destination.join("templates/todos.heml")).expect("template"); fs::read_to_string(destination.join("templates/todos.heml")).expect("template");
let readme = fs::read_to_string(destination.join("README.md")).expect("readme");
let created = fs::read_to_string(destination.join("CREATED.md")).expect("created docs"); let created = fs::read_to_string(destination.join("CREATED.md")).expect("created docs");
assert!(manifest.contains("name = \"hemx-app\"")); assert!(manifest.contains("name = \"hemx-app\""));
@@ -1265,6 +1289,11 @@ mod tests {
assert!(template.contains("data-hemx-slot=\"notice\"")); assert!(template.contains("data-hemx-slot=\"notice\""));
assert!(template.contains("data-hemx-slot=\"todo_row\"")); assert!(template.contains("data-hemx-slot=\"todo_row\""));
assert!(template.contains("h-key=\"row.id\"")); assert!(template.contains("h-key=\"row.id\""));
assert!(readme.contains("# hemx app"));
assert!(readme.contains("cargo run"));
assert!(readme.contains("cargo build --release"));
assert!(readme.contains("templates/todos.heml"));
assert!(!readme.contains("cargo run -p hemx-app"));
assert!(created.contains("keyed row partial")); assert!(created.contains("keyed row partial"));
assert!(created.contains("generated append/replace/remove/dynamic-batch effects")); assert!(created.contains("generated append/replace/remove/dynamic-batch effects"));
assert!(destination.join("src/lib.rs").exists()); assert!(destination.join("src/lib.rs").exists());