Skip to main content

max / makenotwork

bento: expose recipe context + generalize version resolution Add recipe host-functions version()/build_host()/repo()/target()/platform()/ arch() so one per-platform recipe can dispatch across arches without a hard-coded host. Rename version_from_tauri_conf -> version_from_repo with a Cargo.toml fallback and an optional topology version_path (audiofiles has no tauri.conf.json). Update README + example topology; recipes now exist. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-16 18:32 UTC
Commit: 96292c69587439406a731ddb226077a5fe4aa69f
Parent: 6bd9e88
5 files changed, +217 insertions, -17 deletions
@@ -65,6 +65,15 @@ pub struct RecipeCtx {
65 65 pub app: AppId,
66 66 pub version: Version,
67 67 pub target: Target,
68 + /// Name of the host this target builds on (resolved from the topology by the
69 + /// runner). Recipes read it via `build_host()` so one per-platform recipe can
70 + /// dispatch to the right host across arches (linux x86_64 -> fw13, aarch64 ->
71 + /// astra) without hard-coding a host name.
72 + pub build_host: String,
73 + /// The app's checkout path (topology `repo`, `~`-prefixed). Recipes read it
74 + /// via `repo()` to `cd` into the checkout — commands don't auto-cd, and each
75 + /// `sh` is a fresh shell.
76 + pub repo: String,
68 77 pub target_run_id: i64,
69 78 /// Capability-scoped executor per build host. Recipe commands dispatch
70 79 /// through these — the transport (local / ssh / in-session agent) and the
@@ -96,6 +105,8 @@ impl RecipeCtx {
96 105 app: AppId,
97 106 version: Version,
98 107 target: Target,
108 + build_host: String,
109 + repo: String,
99 110 target_run_id: i64,
100 111 execs: Arc<ExecutorMap>,
101 112 host_ssh: HashMap<String, String>,
@@ -109,6 +120,8 @@ impl RecipeCtx {
109 120 app,
110 121 version,
111 122 target,
123 + build_host,
124 + repo,
112 125 target_run_id,
113 126 execs,
114 127 host_ssh,
@@ -306,15 +319,55 @@ fn rhai_err(e: impl std::fmt::Display) -> Box<EvalAltResult> {
306 319 Box::new(EvalAltResult::ErrorRuntime(e.to_string().into(), rhai::Position::NONE))
307 320 }
308 321
309 - /// Read `tauri.conf.json`'s version for `app`, from its checkout on the daemon
310 - /// host. Used by `version_of()` and the runner's default-version path.
311 - pub fn version_from_tauri_conf(repo: &str) -> Result<Version> {
312 - let path = expand_tilde(repo).join("src-tauri").join("tauri.conf.json");
313 - let raw = std::fs::read_to_string(&path)
314 - .with_context(|| format!("reading {}", path.display()))?;
315 - let v: serde_json::Value = serde_json::from_str(&raw).context("parsing tauri.conf.json")?;
316 - let ver = v.get("version").and_then(|x| x.as_str()).context("no `version` in tauri.conf.json")?;
317 - Version::parse(ver).map_err(|e| anyhow::anyhow!(e))
322 + /// Read the app's version from its checkout on the daemon host. With
323 + /// `version_path` set (topology `version_path`), read exactly that file — a
324 + /// `.json` as a Tauri config, anything else as a `Cargo.toml`. Unset (the Tauri
325 + /// default), try `src-tauri/tauri.conf.json` then the root `Cargo.toml`. Used by
326 + /// the runner's default-version path.
327 + pub fn version_from_repo(repo: &str, version_path: Option<&str>) -> Result<Version> {
328 + let root = expand_tilde(repo);
329 + if let Some(vp) = version_path {
330 + let path = root.join(vp);
331 + let raw = std::fs::read_to_string(&path)
332 + .with_context(|| format!("reading version file {}", path.display()))?;
333 + let ver = if vp.ends_with(".json") {
334 + version_from_tauri_json(&raw)?
335 + } else {
336 + version_from_cargo_toml(&raw)?
337 + };
338 + return Version::parse(&ver).map_err(|e| anyhow::anyhow!(e));
339 + }
340 + let tauri_conf = root.join("src-tauri").join("tauri.conf.json");
341 + if tauri_conf.exists() {
342 + let raw = std::fs::read_to_string(&tauri_conf)
343 + .with_context(|| format!("reading {}", tauri_conf.display()))?;
344 + return Version::parse(&version_from_tauri_json(&raw)?).map_err(|e| anyhow::anyhow!(e));
345 + }
346 + let cargo_toml = root.join("Cargo.toml");
347 + let raw = std::fs::read_to_string(&cargo_toml)
348 + .with_context(|| format!("reading {} (no tauri.conf.json either)", cargo_toml.display()))?;
349 + Version::parse(&version_from_cargo_toml(&raw)?).map_err(|e| anyhow::anyhow!(e))
350 + }
351 +
352 + /// Extract `version` from raw `tauri.conf.json` text.
353 + fn version_from_tauri_json(raw: &str) -> Result<String> {
354 + let v: serde_json::Value = serde_json::from_str(raw).context("parsing tauri.conf.json")?;
355 + v.get("version")
356 + .and_then(|x| x.as_str())
357 + .map(str::to_owned)
358 + .context("no `version` in tauri.conf.json")
359 + }
360 +
361 + /// Extract the version from raw `Cargo.toml` text — `[package].version` (a leaf
362 + /// crate) or `[workspace.package].version` (a workspace that sets it).
363 + fn version_from_cargo_toml(raw: &str) -> Result<String> {
364 + let doc: toml::Value = toml::from_str(raw).context("parsing Cargo.toml")?;
365 + doc.get("package")
366 + .and_then(|p| p.get("version"))
367 + .or_else(|| doc.get("workspace").and_then(|w| w.get("package")).and_then(|p| p.get("version")))
368 + .and_then(|v| v.as_str())
369 + .map(str::to_owned)
370 + .context("no `[package].version` or `[workspace.package].version` in Cargo.toml")
318 371 }
319 372
320 373 /// Expand a leading `~/` to `$HOME`. Paths in the topology are written with `~`.
@@ -395,6 +448,39 @@ pub fn build_engine(ctx: Arc<RecipeCtx>) -> Engine {
395 448 });
396 449 }
397 450
451 + // --- version() -> string: the version being built (no-arg form) ---
452 + {
453 + let ctx = ctx.clone();
454 + engine.register_fn("version", move || -> String { ctx.version.to_string() });
455 + }
456 +
457 + // --- build_host() -> string: the host this target builds on ---
458 + {
459 + let ctx = ctx.clone();
460 + engine.register_fn("build_host", move || -> String { ctx.build_host.clone() });
461 + }
462 +
463 + // --- repo() -> string: the app's checkout path (`~`-prefixed) ---
464 + {
465 + let ctx = ctx.clone();
466 + engine.register_fn("repo", move || -> String { ctx.repo.clone() });
467 + }
468 +
469 + // --- target() / platform() / arch(): the target axis, for one per-platform
470 + // recipe to branch on arch (bundle paths differ between x86_64/aarch64). ---
471 + {
472 + let ctx = ctx.clone();
473 + engine.register_fn("target", move || -> String { ctx.target.to_string() });
474 + }
475 + {
476 + let ctx = ctx.clone();
477 + engine.register_fn("platform", move || -> String { ctx.target.platform.as_str().to_string() });
478 + }
479 + {
480 + let ctx = ctx.clone();
481 + engine.register_fn("arch", move || -> String { ctx.target.arch.as_str().to_string() });
482 + }
483 +
398 484 // --- secret(key) -> string (file under secrets_root; never logged) ---
399 485 {
400 486 let ctx = ctx.clone();
@@ -803,6 +889,61 @@ mod tests {
803 889 assert_eq!(expand_tilde("/abs/path"), PathBuf::from("/abs/path"));
804 890 }
805 891
892 + // ---- version resolution ----
893 +
894 + #[test]
895 + fn version_from_tauri_json_reads_version() {
896 + assert_eq!(version_from_tauri_json(r#"{"version":"0.4.2"}"#).unwrap(), "0.4.2");
897 + assert!(version_from_tauri_json(r#"{"productName":"X"}"#).is_err());
898 + }
899 +
900 + #[test]
901 + fn version_from_cargo_toml_prefers_package_then_workspace() {
902 + // A leaf crate's [package].version.
903 + assert_eq!(
904 + version_from_cargo_toml("[package]\nname = \"x\"\nversion = \"0.5.0\"\n").unwrap(),
905 + "0.5.0"
906 + );
907 + // A workspace that sets [workspace.package].version.
908 + assert_eq!(
909 + version_from_cargo_toml("[workspace.package]\nversion = \"1.2.3\"\n").unwrap(),
910 + "1.2.3"
911 + );
912 + // No version anywhere -> error, not a panic.
913 + assert!(version_from_cargo_toml("[workspace]\nmembers = []\n").is_err());
914 + }
915 +
916 + #[test]
917 + fn version_from_repo_default_and_explicit_paths() {
918 + let tmp = tempfile::tempdir().unwrap();
919 + let root = tmp.path();
920 +
921 + // Tauri app: default path reads src-tauri/tauri.conf.json.
922 + let tauri = root.join("tauri");
923 + std::fs::create_dir_all(tauri.join("src-tauri")).unwrap();
924 + std::fs::write(tauri.join("src-tauri/tauri.conf.json"), r#"{"version":"0.4.2"}"#).unwrap();
925 + assert_eq!(
926 + version_from_repo(tauri.to_str().unwrap(), None).unwrap().to_string(),
927 + "0.4.2"
928 + );
929 +
930 + // Workspace egui app: no tauri.conf.json, explicit version_path at a member crate.
931 + let ws = root.join("ws");
932 + std::fs::create_dir_all(ws.join("crates/app")).unwrap();
933 + std::fs::write(ws.join("Cargo.toml"), "[workspace]\nmembers = [\"crates/app\"]\n").unwrap();
934 + std::fs::write(
935 + ws.join("crates/app/Cargo.toml"),
936 + "[package]\nname = \"app\"\nversion = \"0.5.0\"\n",
937 + )
938 + .unwrap();
939 + assert_eq!(
940 + version_from_repo(ws.to_str().unwrap(), Some("crates/app/Cargo.toml"))
941 + .unwrap()
942 + .to_string(),
943 + "0.5.0"
944 + );
945 + }
946 +
806 947 // ---- publish step-success gate ----
807 948
808 949 fn target(s: &str) -> Target {
@@ -32,7 +32,7 @@ pub fn resolve_version(
32 32 .topo
33 33 .app(app)
34 34 .ok_or_else(|| Error::BadRequest(format!("unknown app `{app}`")))?;
35 - engine::version_from_tauri_conf(&cfg.repo).map_err(Error::Other)
35 + engine::version_from_repo(&cfg.repo, cfg.version_path.as_deref()).map_err(Error::Other)
36 36 }
37 37
38 38 /// Validate + default the target list against what the app ships. Unknown app,
@@ -161,10 +161,27 @@ async fn run_target(state: AppState, build_id: i64, app: AppId, version: Version
161 161 return;
162 162 }
163 163
164 + // Host + checkout path for this target, resolved from the topology. Both are
165 + // exposed to the recipe (`build_host()` / `repo()`); a missing host here is
166 + // unreachable (resolve_targets rejected unbuildable targets before /build),
167 + // but fall back rather than panic.
168 + let build_host = state
169 + .topo
170 + .host_for(target)
171 + .map(|h| h.name.clone())
172 + .unwrap_or_default();
173 + let repo = state
174 + .topo
175 + .app(&app)
176 + .map(|a| a.repo.clone())
177 + .unwrap_or_default();
178 +
164 179 let ctx = Arc::new(RecipeCtx::new(
165 180 app.clone(),
166 181 version.clone(),
167 182 target,
183 + build_host,
184 + repo,
168 185 target_run_id,
169 186 state.executors.clone(),
170 187 state.topo.host_ssh(),
@@ -79,6 +79,15 @@ pub struct AppConfig {
79 79 /// Recipe directory relative to the repo (`dist/recipes`).
80 80 #[serde(default = "default_recipe_dir")]
81 81 pub recipe_dir: String,
82 + /// Where to read the release version, relative to the repo. Unset (the
83 + /// default) means `src-tauri/tauri.conf.json` then the root `Cargo.toml` — the
84 + /// Tauri-app path. A non-Tauri workspace app (audiofiles) sets this to the
85 + /// member crate that carries the version, e.g.
86 + /// `crates/audiofiles-app/Cargo.toml`, so the version isn't guessed from the
87 + /// workspace. A `.json` file is read as `tauri.conf.json`; anything else as a
88 + /// `Cargo.toml`.
89 + #[serde(default)]
90 + pub version_path: Option<String>,
82 91 /// Targets this app ships.
83 92 pub targets: Vec<Target>,
84 93 }
@@ -49,9 +49,37 @@ token is required. To operate bentod over the tailnet, bind the tailnet IP in
49 49 unit — bentod refuses to start on a non-loopback bind without it (same posture
50 50 as Sando's `SANDO_API_TOKEN`).
51 51
52 - ## Not yet: builds need recipes
52 + ## Recipes
53 53
54 - Standing up the service makes bentod reachable (TUI/driver, `/state`), but a
55 - real `/build` reads `<app>/<recipe_dir>/<platform>.rhai` from each app's
56 - checkout. Those recipes do not exist yet — writing them (per-app, per-platform)
57 - is the next piece before bentod can actually ship an app.
54 + A real `/build` reads `<app>/<recipe_dir>/<platform>.rhai` from each app's
55 + checkout. The recipes now exist, one per platform per app:
56 +
57 + | App | macOS | iOS | Linux | Windows |
58 + |-----|-------|-----|-------|---------|
59 + | goingson | yes | yes | yes | yes (unsigned, unsupported) |
60 + | audiofiles | yes | — (egui, no iOS) | yes | yes (unsigned, unsupported) |
61 + | balanced_breakfast | pending¹ | pending¹ | yes | yes (unsigned, unsupported) |
62 +
63 + ¹ BB has no Developer ID signing / notarization infra or iOS project yet, so its
64 + macOS/iOS recipes are deferred until that lands. BB ships in a later wave.
65 +
66 + A `linux.rhai` serves both arches — `build_host()` resolves to the native host
67 + the topology assigns the target (`linux/x86_64` → fw13, `linux/aarch64` → astra),
68 + so there is no cross-compilation and no hard-coded host name. macOS/iOS recipes
69 + run on the mbp `agent`-transport host where the Developer ID key is usable.
70 +
71 + ### Recipe host-function vocabulary
72 +
73 + Beyond `step`/`sh`/`sh_ok`/`log`/`collect`/`publish`/`secret`/`env` and the
74 + macOS helpers (`codesign`/`notarize`/`staple`/`verify_gatekeeper`/`keychain_*`),
75 + recipes can read their own context:
76 +
77 + | Function | Returns |
78 + |----------|---------|
79 + | `version()` | the version being built (e.g. `"0.4.2"`) |
80 + | `build_host()` | the host this target builds on (e.g. `"fw13"`) |
81 + | `repo()` | the app's checkout path (`~`-prefixed; `cd` into it — commands don't auto-cd) |
82 + | `target()` / `platform()` / `arch()` | `"linux/x86_64"` / `"linux"` / `"x86_64"` |
83 +
84 + Non-Tauri apps (audiofiles) set `version_path` in the topology to the crate
85 + `Cargo.toml` carrying the version, since they have no `tauri.conf.json`.
@@ -42,11 +42,16 @@ targets = ["macos/aarch64", "ios/universal", "linux/x86_64", "linux/aarch64", "w
42 42 repo = "~/Code/Apps/balanced_breakfast"
43 43 branch = "main"
44 44 recipe_dir = "dist/recipes"
45 - targets = ["macos/aarch64", "ios/universal", "linux/x86_64", "linux/aarch64", "windows/x86_64"]
45 + # macOS/iOS omitted until BB gains Developer ID signing + notarization infra
46 + # (it has no release-macos.sh / iOS project today); only linux + windows recipes
47 + # exist. BB is a later wave regardless.
48 + targets = ["linux/x86_64", "linux/aarch64", "windows/x86_64"]
46 49
47 50 [app.audiofiles]
48 51 repo = "~/Code/Apps/audiofiles"
49 52 branch = "main"
50 53 recipe_dir = "dist/recipes"
51 - # egui desktop app (no Tauri, no iOS).
54 + # egui desktop app (no Tauri, no iOS). No tauri.conf.json, so point the version
55 + # at the binary crate's Cargo.toml.
56 + version_path = "crates/audiofiles-app/Cargo.toml"
52 57 targets = ["macos/aarch64", "linux/x86_64", "linux/aarch64", "windows/x86_64"]