Skip to main content

max / makenotwork

Add bento, ops-core, and egui-updater crate scaffolds Forward-project work seeded during the 2026-06-05 session (launch plan sections U / U.1): - shared/ops-core: shared SSH remote-exec, eventbus, live-log, sqlite, TUI scaffold extracted for both Sando and Bento to depend on. - bento: "Sando for apps" release orchestrator (bentod daemon + ratatui TUI) that builds/signs/notarizes/publishes desktop+mobile artifacts. - shared/egui-updater: generic egui/eframe OTA client crate (MIT, name reserved on crates.io; publish pending). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-07 15:07 UTC
Commit: 9b7f2bbcaf3fddd5f44473aaa3b469bde56fcd3f
Parent: 3789ab2
30 files changed, +5855 insertions, -0 deletions
@@ -0,0 +1,3343 @@
1 + # This file is automatically @generated by Cargo.
2 + # It is not intended for manual editing.
3 + version = 4
4 +
5 + [[package]]
6 + name = "ahash"
7 + version = "0.8.12"
8 + source = "registry+https://github.com/rust-lang/crates.io-index"
9 + checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
10 + dependencies = [
11 + "cfg-if",
12 + "const-random",
13 + "getrandom 0.3.4",
14 + "once_cell",
15 + "version_check",
16 + "zerocopy",
17 + ]
18 +
19 + [[package]]
20 + name = "aho-corasick"
21 + version = "1.1.4"
22 + source = "registry+https://github.com/rust-lang/crates.io-index"
23 + checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
24 + dependencies = [
25 + "memchr",
26 + ]
27 +
28 + [[package]]
29 + name = "allocator-api2"
30 + version = "0.2.21"
31 + source = "registry+https://github.com/rust-lang/crates.io-index"
32 + checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
33 +
34 + [[package]]
35 + name = "android_system_properties"
36 + version = "0.1.5"
37 + source = "registry+https://github.com/rust-lang/crates.io-index"
38 + checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
39 + dependencies = [
40 + "libc",
41 + ]
42 +
43 + [[package]]
44 + name = "anyhow"
45 + version = "1.0.102"
46 + source = "registry+https://github.com/rust-lang/crates.io-index"
47 + checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
48 +
49 + [[package]]
50 + name = "atoi"
51 + version = "2.0.0"
52 + source = "registry+https://github.com/rust-lang/crates.io-index"
53 + checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528"
54 + dependencies = [
55 + "num-traits",
56 + ]
57 +
58 + [[package]]
59 + name = "atomic-waker"
60 + version = "1.1.2"
61 + source = "registry+https://github.com/rust-lang/crates.io-index"
62 + checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
63 +
64 + [[package]]
65 + name = "autocfg"
66 + version = "1.5.1"
67 + source = "registry+https://github.com/rust-lang/crates.io-index"
68 + checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
69 +
70 + [[package]]
71 + name = "axum"
72 + version = "0.8.9"
73 + source = "registry+https://github.com/rust-lang/crates.io-index"
74 + checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
75 + dependencies = [
76 + "axum-core",
77 + "axum-macros",
78 + "base64",
79 + "bytes",
80 + "form_urlencoded",
81 + "futures-util",
82 + "http",
83 + "http-body",
84 + "http-body-util",
85 + "hyper",
86 + "hyper-util",
87 + "itoa",
88 + "matchit",
89 + "memchr",
90 + "mime",
91 + "percent-encoding",
92 + "pin-project-lite",
93 + "serde_core",
94 + "serde_json",
95 + "serde_path_to_error",
96 + "serde_urlencoded",
97 + "sha1",
98 + "sync_wrapper",
99 + "tokio",
100 + "tokio-tungstenite",
101 + "tower",
102 + "tower-layer",
103 + "tower-service",
104 + "tracing",
105 + ]
106 +
107 + [[package]]
108 + name = "axum-core"
109 + version = "0.5.6"
110 + source = "registry+https://github.com/rust-lang/crates.io-index"
111 + checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
112 + dependencies = [
113 + "bytes",
114 + "futures-core",
115 + "http",
116 + "http-body",
117 + "http-body-util",
118 + "mime",
119 + "pin-project-lite",
120 + "sync_wrapper",
121 + "tower-layer",
122 + "tower-service",
123 + "tracing",
124 + ]
125 +
126 + [[package]]
127 + name = "axum-macros"
128 + version = "0.5.1"
129 + source = "registry+https://github.com/rust-lang/crates.io-index"
130 + checksum = "7aa268c23bfbbd2c4363b9cd302a4f504fb2a9dfe7e3451d66f35dd392e20aca"
131 + dependencies = [
132 + "proc-macro2",
133 + "quote",
134 + "syn",
135 + ]
136 +
137 + [[package]]
138 + name = "base64"
139 + version = "0.22.1"
140 + source = "registry+https://github.com/rust-lang/crates.io-index"
141 + checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
142 +
143 + [[package]]
144 + name = "base64ct"
145 + version = "1.8.3"
146 + source = "registry+https://github.com/rust-lang/crates.io-index"
147 + checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
148 +
149 + [[package]]
150 + name = "bento-daemon"
151 + version = "0.1.0"
152 + dependencies = [
153 + "anyhow",
154 + "axum",
155 + "chrono",
156 + "http-body-util",
157 + "metrics",
158 + "metrics-exporter-prometheus",
159 + "ops-core",
160 + "reqwest",
161 + "rhai",
162 + "semver",
163 + "serde",
164 + "serde_json",
165 + "sqlx",
166 + "tempfile",
167 + "thiserror",
168 + "tokio",
169 + "toml",
170 + "tower",
171 + "tracing",
172 + "tracing-subscriber",
173 + ]
174 +
175 + [[package]]
176 + name = "bitflags"
177 + version = "2.13.0"
178 + source = "registry+https://github.com/rust-lang/crates.io-index"
179 + checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
180 + dependencies = [
181 + "serde_core",
182 + ]
183 +
184 + [[package]]
185 + name = "block-buffer"
186 + version = "0.10.4"
187 + source = "registry+https://github.com/rust-lang/crates.io-index"
188 + checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
189 + dependencies = [
190 + "generic-array",
191 + ]
192 +
193 + [[package]]
194 + name = "bumpalo"
195 + version = "3.20.3"
196 + source = "registry+https://github.com/rust-lang/crates.io-index"
197 + checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
198 +
199 + [[package]]
200 + name = "byteorder"
201 + version = "1.5.0"
202 + source = "registry+https://github.com/rust-lang/crates.io-index"
203 + checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
204 +
205 + [[package]]
206 + name = "bytes"
207 + version = "1.11.1"
208 + source = "registry+https://github.com/rust-lang/crates.io-index"
209 + checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
210 +
211 + [[package]]
212 + name = "cc"
213 + version = "1.2.63"
214 + source = "registry+https://github.com/rust-lang/crates.io-index"
215 + checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f"
216 + dependencies = [
217 + "find-msvc-tools",
218 + "shlex",
219 + ]
220 +
221 + [[package]]
222 + name = "cfg-if"
223 + version = "1.0.4"
224 + source = "registry+https://github.com/rust-lang/crates.io-index"
225 + checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
226 +
227 + [[package]]
228 + name = "cfg_aliases"
229 + version = "0.2.1"
230 + source = "registry+https://github.com/rust-lang/crates.io-index"
231 + checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
232 +
233 + [[package]]
234 + name = "chrono"
235 + version = "0.4.45"
236 + source = "registry+https://github.com/rust-lang/crates.io-index"
237 + checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
238 + dependencies = [
239 + "iana-time-zone",
240 + "js-sys",
241 + "num-traits",
242 + "serde",
243 + "wasm-bindgen",
244 + "windows-link",
245 + ]
246 +
247 + [[package]]
248 + name = "concurrent-queue"
249 + version = "2.5.0"
250 + source = "registry+https://github.com/rust-lang/crates.io-index"
251 + checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
252 + dependencies = [
253 + "crossbeam-utils",
254 + ]
255 +
256 + [[package]]
257 + name = "const-oid"
258 + version = "0.9.6"
259 + source = "registry+https://github.com/rust-lang/crates.io-index"
260 + checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
261 +
262 + [[package]]
263 + name = "const-random"
264 + version = "0.1.18"
265 + source = "registry+https://github.com/rust-lang/crates.io-index"
266 + checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359"
267 + dependencies = [
268 + "const-random-macro",
269 + ]
270 +
271 + [[package]]
272 + name = "const-random-macro"
273 + version = "0.1.16"
274 + source = "registry+https://github.com/rust-lang/crates.io-index"
275 + checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e"
276 + dependencies = [
277 + "getrandom 0.2.17",
278 + "once_cell",
279 + "tiny-keccak",
280 + ]
281 +
282 + [[package]]
283 + name = "core-foundation-sys"
284 + version = "0.8.7"
285 + source = "registry+https://github.com/rust-lang/crates.io-index"
286 + checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
287 +
288 + [[package]]
289 + name = "cpufeatures"
290 + version = "0.2.17"
291 + source = "registry+https://github.com/rust-lang/crates.io-index"
292 + checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
293 + dependencies = [
294 + "libc",
295 + ]
296 +
297 + [[package]]
298 + name = "crc"
299 + version = "3.4.0"
300 + source = "registry+https://github.com/rust-lang/crates.io-index"
301 + checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d"
302 + dependencies = [
303 + "crc-catalog",
304 + ]
305 +
306 + [[package]]
307 + name = "crc-catalog"
308 + version = "2.5.0"
309 + source = "registry+https://github.com/rust-lang/crates.io-index"
310 + checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853"
311 +
312 + [[package]]
313 + name = "crossbeam-epoch"
314 + version = "0.9.18"
315 + source = "registry+https://github.com/rust-lang/crates.io-index"
316 + checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
317 + dependencies = [
318 + "crossbeam-utils",
319 + ]
320 +
321 + [[package]]
322 + name = "crossbeam-queue"
323 + version = "0.3.12"
324 + source = "registry+https://github.com/rust-lang/crates.io-index"
325 + checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115"
326 + dependencies = [
327 + "crossbeam-utils",
328 + ]
329 +
330 + [[package]]
331 + name = "crossbeam-utils"
332 + version = "0.8.21"
333 + source = "registry+https://github.com/rust-lang/crates.io-index"
334 + checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
335 +
336 + [[package]]
337 + name = "crunchy"
338 + version = "0.2.4"
339 + source = "registry+https://github.com/rust-lang/crates.io-index"
340 + checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
341 +
342 + [[package]]
343 + name = "crypto-common"
344 + version = "0.1.7"
345 + source = "registry+https://github.com/rust-lang/crates.io-index"
346 + checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
347 + dependencies = [
348 + "generic-array",
349 + "typenum",
350 + ]
351 +
352 + [[package]]
353 + name = "data-encoding"
354 + version = "2.11.0"
355 + source = "registry+https://github.com/rust-lang/crates.io-index"
356 + checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8"
357 +
358 + [[package]]
359 + name = "der"
360 + version = "0.7.10"
361 + source = "registry+https://github.com/rust-lang/crates.io-index"
362 + checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
363 + dependencies = [
364 + "const-oid",
365 + "pem-rfc7468",
366 + "zeroize",
367 + ]
368 +
369 + [[package]]
370 + name = "digest"
371 + version = "0.10.7"
372 + source = "registry+https://github.com/rust-lang/crates.io-index"
373 + checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
374 + dependencies = [
375 + "block-buffer",
376 + "const-oid",
377 + "crypto-common",
378 + "subtle",
379 + ]
380 +
381 + [[package]]
382 + name = "displaydoc"
383 + version = "0.2.6"
384 + source = "registry+https://github.com/rust-lang/crates.io-index"
385 + checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f"
386 + dependencies = [
387 + "proc-macro2",
388 + "quote",
389 + "syn",
390 + ]
391 +
392 + [[package]]
393 + name = "dotenvy"
394 + version = "0.15.7"
395 + source = "registry+https://github.com/rust-lang/crates.io-index"
396 + checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
397 +
398 + [[package]]
399 + name = "either"
400 + version = "1.16.0"
401 + source = "registry+https://github.com/rust-lang/crates.io-index"
402 + checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
403 + dependencies = [
404 + "serde",
405 + ]
406 +
407 + [[package]]
408 + name = "equivalent"
409 + version = "1.0.2"
410 + source = "registry+https://github.com/rust-lang/crates.io-index"
411 + checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
412 +
413 + [[package]]
414 + name = "errno"
415 + version = "0.3.14"
416 + source = "registry+https://github.com/rust-lang/crates.io-index"
417 + checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
418 + dependencies = [
419 + "libc",
420 + "windows-sys 0.61.2",
421 + ]
422 +
423 + [[package]]
424 + name = "etcetera"
425 + version = "0.8.0"
426 + source = "registry+https://github.com/rust-lang/crates.io-index"
427 + checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943"
428 + dependencies = [
429 + "cfg-if",
430 + "home",
431 + "windows-sys 0.48.0",
432 + ]
433 +
434 + [[package]]
435 + name = "event-listener"
436 + version = "5.4.1"
437 + source = "registry+https://github.com/rust-lang/crates.io-index"
438 + checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab"
439 + dependencies = [
440 + "concurrent-queue",
441 + "parking",
442 + "pin-project-lite",
443 + ]
444 +
445 + [[package]]
446 + name = "evmap"
447 + version = "11.0.0"
448 + source = "registry+https://github.com/rust-lang/crates.io-index"
449 + checksum = "1b8874945f036109c72242964c1174cf99434e30cfa45bf45fedc983f50046f8"
450 + dependencies = [
451 + "hashbag",
452 + "left-right",
453 + "smallvec",
454 + ]
455 +
456 + [[package]]
457 + name = "fastrand"
458 + version = "2.4.1"
459 + source = "registry+https://github.com/rust-lang/crates.io-index"
460 + checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
461 +
462 + [[package]]
463 + name = "find-msvc-tools"
464 + version = "0.1.9"
465 + source = "registry+https://github.com/rust-lang/crates.io-index"
466 + checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
467 +
468 + [[package]]
469 + name = "flume"
470 + version = "0.11.1"
471 + source = "registry+https://github.com/rust-lang/crates.io-index"
472 + checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095"
473 + dependencies = [
474 + "futures-core",
475 + "futures-sink",
476 + "spin 0.9.8",
477 + ]
478 +
479 + [[package]]
480 + name = "foldhash"
481 + version = "0.1.5"
482 + source = "registry+https://github.com/rust-lang/crates.io-index"
483 + checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
484 +
485 + [[package]]
486 + name = "foldhash"
487 + version = "0.2.0"
488 + source = "registry+https://github.com/rust-lang/crates.io-index"
489 + checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
490 +
491 + [[package]]
492 + name = "form_urlencoded"
493 + version = "1.2.2"
494 + source = "registry+https://github.com/rust-lang/crates.io-index"
495 + checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
496 + dependencies = [
497 + "percent-encoding",
498 + ]
499 +
500 + [[package]]
Lines truncated
@@ -0,0 +1,33 @@
1 + [package]
2 + name = "bento-daemon"
3 + version = "0.1.0"
4 + edition = "2024"
5 + license = "MIT"
6 +
7 + [[bin]]
8 + name = "bentod"
9 + path = "src/main.rs"
10 +
11 + [dependencies]
12 + ops-core = { path = "../../shared/ops-core" }
13 + axum = { version = "0.8.8", features = ["macros", "ws"] }
14 + tokio = { version = "1.50.0", features = ["macros", "rt-multi-thread", "net", "signal", "fs", "process", "sync"] }
15 + serde = { version = "1.0.228", features = ["derive"] }
16 + serde_json = "1"
17 + toml = "0.8"
18 + sqlx = { version = "0.8.6", features = ["runtime-tokio", "sqlite", "chrono", "migrate"] }
19 + rhai = { version = "1.20", features = ["sync"] }
20 + tracing = "0.1.44"
21 + tracing-subscriber = { version = "0.3.22", features = ["env-filter", "json"] }
22 + metrics = "0.24"
23 + metrics-exporter-prometheus = { version = "0.18.1", default-features = false }
24 + anyhow = "1.0.102"
25 + thiserror = "2.0.18"
26 + chrono = { version = "0.4", features = ["serde"] }
27 + semver = { version = "1.0", features = ["serde"] }
28 +
29 + [dev-dependencies]
30 + tempfile = "3.20"
31 + tower = { version = "0.5", features = ["util"] }
32 + http-body-util = "0.1"
33 + reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
@@ -0,0 +1,50 @@
1 + -- Bento run history. Three nested tables mirror the App x Target x Step model:
2 + -- a build fans out to per-target runs, each of which walks the step sequence.
3 +
4 + CREATE TABLE builds (
5 + id INTEGER PRIMARY KEY AUTOINCREMENT,
6 + app TEXT NOT NULL,
7 + version TEXT NOT NULL,
8 + status TEXT NOT NULL, -- pending | running | ok | failed
9 + created_at TEXT NOT NULL,
10 + finished_at TEXT
11 + );
12 +
13 + CREATE TABLE target_runs (
14 + id INTEGER PRIMARY KEY AUTOINCREMENT,
15 + build_id INTEGER NOT NULL REFERENCES builds(id) ON DELETE CASCADE,
16 + app TEXT NOT NULL,
17 + version TEXT NOT NULL,
18 + target TEXT NOT NULL, -- "platform/arch"
19 + status TEXT NOT NULL, -- pending | running | ok | failed
20 + current_step TEXT, -- step name while running
21 + error TEXT,
22 + started_at TEXT NOT NULL,
23 + finished_at TEXT
24 + );
25 +
26 + CREATE INDEX target_runs_build ON target_runs(build_id);
27 +
28 + CREATE TABLE step_runs (
29 + id INTEGER PRIMARY KEY AUTOINCREMENT,
30 + target_run_id INTEGER NOT NULL REFERENCES target_runs(id) ON DELETE CASCADE,
31 + step TEXT NOT NULL, -- canonical Step name
32 + status TEXT NOT NULL, -- running | ok | failed
33 + log_ref TEXT, -- path under logs_root
34 + started_at TEXT NOT NULL,
35 + finished_at TEXT
36 + );
37 +
38 + CREATE INDEX step_runs_target ON step_runs(target_run_id);
39 +
40 + -- Published releases, for publish idempotency + version-monotonicity guards.
41 + CREATE TABLE releases (
42 + id INTEGER PRIMARY KEY AUTOINCREMENT,
43 + app TEXT NOT NULL,
44 + target TEXT NOT NULL,
45 + version TEXT NOT NULL,
46 + channel TEXT NOT NULL,
47 + artifact_hash TEXT,
48 + published_at TEXT NOT NULL,
49 + UNIQUE(app, target, channel, version)
50 + );
@@ -0,0 +1,48 @@
1 + //! Daemon-local config (`bento-daemon.toml`) — paths and listen address that
2 + //! belong to the machine bentod runs on, not to the build matrix (which lives
3 + //! in the separate topology file, see [`crate::topology`]).
4 +
5 + use anyhow::{Context, Result};
6 + use serde::Deserialize;
7 + use std::path::PathBuf;
8 +
9 + #[derive(Debug, Clone, Deserialize)]
10 + pub struct Config {
11 + pub listen: String,
12 + pub db_path: PathBuf,
13 + pub topology_path: PathBuf,
14 + /// Root of the Syncthing private layer (`~/Code/_private`); the `secret()`
15 + /// host function reads credential files relative to here. Never logged.
16 + pub secrets_root: PathBuf,
17 + /// Where collected artifacts land (`<dist_root>/<app>/<version>/`).
18 + pub dist_root: PathBuf,
19 + /// Root for per-step run logs
20 + /// (`<logs_root>/<app>/<version>/<target>/<step>.log`).
21 + #[serde(default = "default_logs_root")]
22 + pub logs_root: PathBuf,
23 + }
24 +
25 + fn default_logs_root() -> PathBuf {
26 + PathBuf::from("/srv/bento/logs")
27 + }
28 +
29 + impl Config {
30 + pub fn load() -> Result<Self> {
31 + let path = std::env::var("BENTO_CONFIG").unwrap_or_else(|_| "bento-daemon.toml".into());
32 + let raw = std::fs::read_to_string(&path)
33 + .with_context(|| format!("reading daemon config at {path}"))?;
34 + Ok(toml::from_str(&raw)?)
35 + }
36 +
37 + #[cfg(test)]
38 + pub fn for_tests(root: &std::path::Path) -> Self {
39 + Self {
40 + listen: "127.0.0.1:0".into(),
41 + db_path: root.join("bento.db"),
42 + topology_path: root.join("bento.toml"),
43 + secrets_root: root.join("secrets"),
44 + dist_root: root.join("dist"),
45 + logs_root: root.join("logs"),
46 + }
47 + }
48 + }
@@ -0,0 +1,14 @@
1 + //! Database access. Connection comes from `ops_core::sqlite`; migrations are
2 + //! per-crate (the `sqlx::migrate!` path is compile-time, relative to here).
3 +
4 + use anyhow::Result;
5 + use sqlx::SqlitePool;
6 + use std::path::Path;
7 +
8 + pub use ops_core::sqlite::connect;
9 +
10 + pub async fn open(path: &Path) -> Result<SqlitePool> {
11 + let pool = connect(path).await?;
12 + sqlx::migrate!("./migrations").run(&pool).await?;
13 + Ok(pool)
14 + }
@@ -0,0 +1,343 @@
1 + //! Domain vocabulary for Bento — the types every module speaks.
2 + //!
3 + //! Bento's axes are App x Target x Step. A `(app, target)` resolves to a Rhai
4 + //! recipe; the recipe walks the canonical [`Step`] sequence. Newtypes carry
5 + //! the boundary parse: a [`Target`] exists because some `"platform/arch"`
6 + //! string validated, so downstream code never re-parses.
7 +
8 + use serde::{Deserialize, Serialize};
9 + use std::fmt;
10 + use std::str::FromStr;
11 +
12 + // ---------------------------------------------------------------------
13 + // App identifier
14 + // ---------------------------------------------------------------------
15 +
16 + /// An app Bento can release: `goingson`, `balanced_breakfast`, `audiofiles`.
17 + #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
18 + #[serde(transparent)]
19 + pub struct AppId(String);
20 +
21 + impl AppId {
22 + pub fn new(s: impl Into<String>) -> Self {
23 + Self(s.into())
24 + }
25 + pub fn as_str(&self) -> &str {
26 + &self.0
27 + }
28 + }
29 +
30 + impl fmt::Display for AppId {
31 + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
32 + self.0.fmt(f)
33 + }
34 + }
35 +
36 + impl From<&str> for AppId {
37 + fn from(s: &str) -> Self {
38 + Self(s.to_owned())
39 + }
40 + }
41 +
42 + // ---------------------------------------------------------------------
43 + // Platform / Arch / Target
44 + // ---------------------------------------------------------------------
45 +
46 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
47 + #[serde(rename_all = "snake_case")]
48 + pub enum Platform {
49 + Macos,
50 + Ios,
51 + Linux,
52 + Windows,
53 + Android,
54 + }
55 +
56 + impl Platform {
57 + pub fn as_str(self) -> &'static str {
58 + match self {
59 + Platform::Macos => "macos",
60 + Platform::Ios => "ios",
61 + Platform::Linux => "linux",
62 + Platform::Windows => "windows",
63 + Platform::Android => "android",
64 + }
65 + }
66 + }
67 +
68 + impl FromStr for Platform {
69 + type Err = String;
70 + fn from_str(s: &str) -> Result<Self, Self::Err> {
71 + Ok(match s {
72 + "macos" => Platform::Macos,
73 + "ios" => Platform::Ios,
74 + "linux" => Platform::Linux,
75 + "windows" => Platform::Windows,
76 + "android" => Platform::Android,
77 + other => return Err(format!("unknown platform `{other}`")),
78 + })
79 + }
80 + }
81 +
82 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
83 + #[serde(rename_all = "snake_case")]
84 + pub enum Arch {
85 + Aarch64,
86 + X86_64,
87 + Universal,
88 + }
89 +
90 + impl Arch {
91 + pub fn as_str(self) -> &'static str {
92 + match self {
93 + Arch::Aarch64 => "aarch64",
94 + Arch::X86_64 => "x86_64",
95 + Arch::Universal => "universal",
96 + }
97 + }
98 + }
99 +
100 + impl FromStr for Arch {
101 + type Err = String;
102 + fn from_str(s: &str) -> Result<Self, Self::Err> {
103 + Ok(match s {
104 + "aarch64" => Arch::Aarch64,
105 + "x86_64" => Arch::X86_64,
106 + "universal" => Arch::Universal,
107 + other => return Err(format!("unknown arch `{other}`")),
108 + })
109 + }
110 + }
111 +
112 + /// A build target: `(platform, arch)`, rendered `platform/arch`
113 + /// (e.g. `macos/aarch64`). This is the dispatch unit — a target only runs on a
114 + /// host that declares it can build it natively.
115 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
116 + pub struct Target {
117 + pub platform: Platform,
118 + pub arch: Arch,
119 + }
120 +
121 + impl Target {
122 + pub fn new(platform: Platform, arch: Arch) -> Self {
123 + Self { platform, arch }
124 + }
125 + }
126 +
127 + impl fmt::Display for Target {
128 + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
129 + write!(f, "{}/{}", self.platform.as_str(), self.arch.as_str())
130 + }
131 + }
132 +
133 + impl FromStr for Target {
134 + type Err = String;
135 + fn from_str(s: &str) -> Result<Self, Self::Err> {
136 + let (p, a) = s.split_once('/').ok_or_else(|| format!("target `{s}` is not `platform/arch`"))?;
137 + Ok(Target::new(p.parse()?, a.parse()?))
138 + }
139 + }
140 +
141 + // Round-trip through JSON / TOML as the `platform/arch` string.
142 + impl Serialize for Target {
143 + fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
144 + s.serialize_str(&self.to_string())
145 + }
146 + }
147 +
148 + impl<'de> Deserialize<'de> for Target {
149 + fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
150 + let s = String::deserialize(d)?;
151 + s.parse().map_err(serde::de::Error::custom)
152 + }
153 + }
154 +
155 + // ---------------------------------------------------------------------
156 + // Step
157 + // ---------------------------------------------------------------------
158 +
159 + /// The canonical release step sequence. A recipe marks transitions by calling
160 + /// the `step(name)` host function; not every platform uses every step (Linux
161 + /// skips sign/notarize/staple). The TUI renders these as matrix columns.
162 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
163 + #[serde(rename_all = "snake_case")]
164 + pub enum Step {
165 + Checkout,
166 + Prebuild,
167 + Build,
168 + Sign,
169 + Notarize,
170 + Staple,
171 + Verify,
172 + Package,
173 + Publish,
174 + Collect,
175 + }
176 +
177 + impl Step {
178 + /// All steps in canonical order — the matrix column set.
179 + pub const ALL: [Step; 10] = [
180 + Step::Checkout,
181 + Step::Prebuild,
182 + Step::Build,
183 + Step::Sign,
184 + Step::Notarize,
185 + Step::Staple,
186 + Step::Verify,
187 + Step::Package,
188 + Step::Publish,
189 + Step::Collect,
190 + ];
191 +
192 + pub fn as_str(self) -> &'static str {
193 + match self {
194 + Step::Checkout => "checkout",
195 + Step::Prebuild => "prebuild",
196 + Step::Build => "build",
197 + Step::Sign => "sign",
198 + Step::Notarize => "notarize",
199 + Step::Staple => "staple",
200 + Step::Verify => "verify",
201 + Step::Package => "package",
202 + Step::Publish => "publish",
203 + Step::Collect => "collect",
204 + }
205 + }
206 + }
207 +
208 + impl fmt::Display for Step {
209 + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
210 + f.write_str(self.as_str())
211 + }
212 + }
213 +
214 + impl FromStr for Step {
215 + type Err = String;
216 + fn from_str(s: &str) -> Result<Self, Self::Err> {
217 + Step::ALL
218 + .into_iter()
219 + .find(|st| st.as_str() == s)
220 + .ok_or_else(|| format!("unknown step `{s}`"))
221 + }
222 + }
223 +
224 + // ---------------------------------------------------------------------
225 + // Version (semver)
226 + // ---------------------------------------------------------------------
227 +
228 + /// App semver (e.g. `0.4.1`), read from `tauri.conf.json` or supplied to
229 + /// `/build`. Stored as TEXT.
230 + #[derive(Debug, Clone, PartialEq, Eq, Hash)]
231 + pub struct Version(semver::Version);
232 +
233 + impl Version {
234 + pub fn parse(s: &str) -> Result<Self, String> {
235 + semver::Version::parse(s)
236 + .map(Self)
237 + .map_err(|e| format!("invalid semver `{s}`: {e}"))
238 + }
239 + }
240 +
241 + impl fmt::Display for Version {
242 + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
243 + self.0.fmt(f)
244 + }
245 + }
246 +
247 + impl FromStr for Version {
248 + type Err = String;
249 + fn from_str(s: &str) -> Result<Self, Self::Err> {
250 + Version::parse(s)
251 + }
252 + }
253 +
254 + impl Serialize for Version {
255 + fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
256 + s.serialize_str(&self.to_string())
257 + }
258 + }
259 +
260 + impl<'de> Deserialize<'de> for Version {
261 + fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
262 + let s = String::deserialize(d)?;
263 + Version::parse(&s).map_err(serde::de::Error::custom)
264 + }
265 + }
266 +
267 + // ---------------------------------------------------------------------
268 + // StepRunId — primary key of `step_runs`, used to key live tails (Ord so the
269 + // TUI can iterate chronologically, like Sando's GateRunId).
270 + // ---------------------------------------------------------------------
271 +
272 + #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
273 + #[serde(transparent)]
274 + pub struct StepRunId(pub i64);
275 +
276 + impl fmt::Display for StepRunId {
277 + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
278 + self.0.fmt(f)
279 + }
280 + }
281 +
282 + /// Outcome of a step / target / build.
283 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
284 + #[serde(rename_all = "snake_case")]
285 + pub enum Status {
286 + Pending,
287 + Running,
288 + Ok,
289 + Failed,
290 + }
291 +
292 + impl Status {
293 + pub fn as_str(self) -> &'static str {
294 + match self {
295 + Status::Pending => "pending",
296 + Status::Running => "running",
297 + Status::Ok => "ok",
298 + Status::Failed => "failed",
299 + }
300 + }
301 + }
302 +
303 + #[cfg(test)]
304 + mod tests {
305 + use super::*;
306 +
307 + #[test]
308 + fn target_roundtrips() {
309 + let t: Target = "macos/aarch64".parse().unwrap();
310 + assert_eq!(t.platform, Platform::Macos);
311 + assert_eq!(t.arch, Arch::Aarch64);
312 + assert_eq!(t.to_string(), "macos/aarch64");
313 + }
314 +
315 + #[test]
316 + fn target_rejects_garbage() {
317 + assert!("macos".parse::<Target>().is_err());
318 + assert!("mac/aarch64".parse::<Target>().is_err());
319 + assert!("macos/sparc".parse::<Target>().is_err());
320 + }
321 +
322 + #[test]
323 + fn target_json_is_the_string() {
324 + let t: Target = "linux/x86_64".parse().unwrap();
325 + assert_eq!(serde_json::to_string(&t).unwrap(), "\"linux/x86_64\"");
326 + let back: Target = serde_json::from_str("\"linux/x86_64\"").unwrap();
327 + assert_eq!(back, t);
328 + }
329 +
330 + #[test]
331 + fn step_roundtrips() {
332 + for s in Step::ALL {
333 + assert_eq!(s.as_str().parse::<Step>().unwrap(), s);
334 + }
335 + assert!("frobnicate".parse::<Step>().is_err());
336 + }
337 +
338 + #[test]
339 + fn version_parses_semver() {
340 + assert_eq!(Version::parse("0.4.1").unwrap().to_string(), "0.4.1");
341 + assert!(Version::parse("v0.4").is_err());
342 + }
343 + }
@@ -0,0 +1,620 @@
1 + //! Rhai recipe engine + host-function API.
2 + //!
3 + //! A `(app, target)` resolves to a `.rhai` recipe composed from a shared step
4 + //! vocabulary. The daemon embeds Rhai and registers the host functions recipes
5 + //! call; the recipe is the orchestration, the host functions are the
6 + //! privileged primitives (run a command, read a secret, collect artifacts,
7 + //! publish). Recipes are otherwise sandboxed — no arbitrary FS/network except
8 + //! through these functions — matching the Balanced Breakfast plugin model.
9 + //!
10 + //! Rhai is synchronous; the engine runs each recipe on a blocking thread
11 + //! (`spawn_blocking`, see [`crate::runner`]) and host functions bridge to async
12 + //! work via `Handle::block_on`. That is sound only off a runtime worker thread,
13 + //! which `spawn_blocking` guarantees.
14 +
15 + use crate::config::Config;
16 + use crate::domain::{AppId, Status, Step, StepRunId, Target, Version};
17 + use crate::events::{self, Event, EventTx};
18 + use crate::ota::{OtaRegistry, Release};
19 + use anyhow::{Context as _, Result};
20 + use ops_core::live_log::LiveLog;
21 + use ops_core::remote::RemoteHost;
22 + use rhai::{Engine, EvalAltResult, Map};
23 + use sqlx::SqlitePool;
24 + use std::collections::HashMap;
25 + use std::path::{Path, PathBuf};
26 + use std::sync::{Arc, Mutex};
27 + use tokio::runtime::Handle;
28 + use tokio::sync::Mutex as AsyncMutex;
29 +
30 + /// The currently-open step within a recipe run: its DB row id, which step it
31 + /// is, and the live-log sink that `sh`/`log` stream into.
32 + struct StepState {
33 + run_id: StepRunId,
34 + step: Step,
35 + log: Arc<AsyncMutex<LiveLog>>,
36 + }
37 +
38 + /// Everything a recipe's host functions need, shared (Arc) into each closure.
39 + pub struct RecipeCtx {
40 + pub app: AppId,
41 + pub version: Version,
42 + pub target: Target,
43 + pub target_run_id: i64,
44 + pub hosts: HashMap<String, RemoteHost>,
45 + pub pool: SqlitePool,
46 + pub events: EventTx,
47 + pub cfg: Arc<Config>,
48 + pub ota: Arc<OtaRegistry>,
49 + pub rt: Handle,
50 + current: Mutex<Option<StepState>>,
51 + }
52 +
53 + impl RecipeCtx {
54 + #[allow(clippy::too_many_arguments)]
55 + pub fn new(
56 + app: AppId,
57 + version: Version,
58 + target: Target,
59 + target_run_id: i64,
60 + hosts: HashMap<String, RemoteHost>,
61 + pool: SqlitePool,
62 + events: EventTx,
63 + cfg: Arc<Config>,
64 + ota: Arc<OtaRegistry>,
65 + rt: Handle,
66 + ) -> Self {
67 + Self {
68 + app,
69 + version,
70 + target,
71 + target_run_id,
72 + hosts,
73 + pool,
74 + events,
75 + cfg,
76 + ota,
77 + rt,
78 + current: Mutex::new(None),
79 + }
80 + }
81 +
82 + fn now() -> String {
83 + chrono::Utc::now().to_rfc3339()
84 + }
85 +
86 + /// `<logs_root>/<app>/<version>/<target-with-slash-as-dash>/<step>.log`.
87 + fn log_path(&self, step: Step) -> PathBuf {
88 + let target_dir = self.target.to_string().replace('/', "-");
89 + self.cfg
90 + .logs_root
91 + .join(self.app.as_str())
92 + .join(self.version.to_string())
93 + .join(target_dir)
94 + .join(format!("{}.log", step.as_str()))
95 + }
96 +
97 + /// Close the previous step (as `Ok`), open a new one: insert its DB row,
98 + /// open a live log whose chunks broadcast `StepLogChunk`, emit `StepStart`.
99 + fn begin_step(self: &Arc<Self>, step: Step) -> Result<()> {
100 + self.finish_step(Status::Ok)?;
101 + let me = self.clone();
102 + let run_id = self.rt.block_on(async move {
103 + let started = Self::now();
104 + let log_ref = me.log_path(step).to_string_lossy().into_owned();
105 + let id: i64 = sqlx::query_scalar(
106 + "INSERT INTO step_runs (target_run_id, step, status, log_ref, started_at)
107 + VALUES (?, ?, 'running', ?, ?) RETURNING id",
108 + )
109 + .bind(me.target_run_id)
110 + .bind(step.as_str())
111 + .bind(&log_ref)
112 + .bind(&started)
113 + .fetch_one(&me.pool)
114 + .await
115 + .context("insert step_run")?;
116 + sqlx::query("UPDATE target_runs SET current_step = ? WHERE id = ?")
117 + .bind(step.as_str())
118 + .bind(me.target_run_id)
119 + .execute(&me.pool)
120 + .await
121 + .context("update current_step")?;
122 + anyhow::Ok(StepRunId(id))
123 + })?;
124 +
125 + // Live log: each chunk fans out as a StepLogChunk event keyed by run_id.
126 + let events = self.events.clone();
127 + let cb_run_id = run_id;
128 + let log = self.rt.block_on(LiveLog::open(
129 + self.log_path(step),
130 + Box::new(move |seq, text| {
131 + events::emit(
132 + &events,
133 + Event::StepLogChunk { run_id: cb_run_id, seq, text: text.to_string() },
134 + );
135 + }),
136 + ));
137 +
138 + events::emit(
139 + &self.events,
140 + Event::StepStart {
141 + run_id,
142 + app: self.app.clone(),
143 + version: self.version.clone(),
144 + target: self.target,
145 + step,
146 + },
147 + );
148 +
149 + *self.current.lock().unwrap() = Some(StepState {
150 + run_id,
151 + step,
152 + log: Arc::new(AsyncMutex::new(log)),
153 + });
154 + Ok(())
155 + }
156 +
157 + /// Finalize the open step (if any): close its log, stamp the DB row, emit
158 + /// `StepDone`. Idempotent when no step is open.
159 + pub fn finish_step(self: &Arc<Self>, status: Status) -> Result<()> {
160 + let st = self.current.lock().unwrap().take();
161 + let Some(st) = st else { return Ok(()) };
162 + let me = self.clone();
163 + self.rt.block_on(async move {
164 + // Drop all log refs so the sink can be owned + flushed.
165 + if let Ok(m) = Arc::try_unwrap(st.log) {
166 + m.into_inner().close().await;
167 + }
168 + let _ = sqlx::query(
169 + "UPDATE step_runs SET status = ?, finished_at = ? WHERE id = ?",
170 + )
171 + .bind(status.as_str())
172 + .bind(Self::now())
173 + .bind(st.run_id.0)
174 + .execute(&me.pool)
175 + .await;
176 + });
177 + events::emit(
178 + &self.events,
179 + Event::StepDone {
180 + run_id: st.run_id,
181 + app: self.app.clone(),
182 + target: self.target,
183 + step: st.step,
184 + status,
185 + },
186 + );
187 + Ok(())
188 + }
189 +
190 + /// Ensure a step is open; default to `Build` if a recipe runs a command
191 + /// before declaring one.
192 + fn ensure_step(self: &Arc<Self>) -> Result<Arc<AsyncMutex<LiveLog>>> {
193 + if self.current.lock().unwrap().is_none() {
194 + self.begin_step(Step::Build)?;
195 + }
196 + Ok(self.current.lock().unwrap().as_ref().unwrap().log.clone())
197 + }
198 +
199 + /// The step currently open, or `Build` as a default for failure
200 + /// attribution before any step was declared.
201 + pub fn current_step(&self) -> Step {
202 + self.current.lock().unwrap().as_ref().map(|s| s.step).unwrap_or(Step::Build)
203 + }
204 +
205 + fn host(&self, name: &str) -> Result<RemoteHost> {
206 + self.hosts
207 + .get(name)
208 + .cloned()
209 + .ok_or_else(|| anyhow::anyhow!("unknown build host `{name}` (not in topology)"))
210 + }
211 +
212 + /// Run `cmd` on `host`, streaming into the current step's log. Returns
213 + /// exit code + a tail of stdout for the recipe to branch on.
214 + fn run(self: &Arc<Self>, host: &str, cmd: &str) -> Result<(i32, String)> {
215 + let sink = self.ensure_step()?;
216 + let host = self.host(host)?;
217 + let cmd = cmd.to_string();
218 + let out = self.rt.block_on(async move { host.run_streaming(&cmd, sink).await })?;
219 + let code = out.status.code().unwrap_or(-1);
220 + let stdout = String::from_utf8_lossy(&out.stdout);
221 + let tail: String = stdout.chars().rev().take(2000).collect::<Vec<_>>().into_iter().rev().collect();
222 + Ok((code, tail))
223 + }
224 + }
225 +
226 + // ----- error bridging: anyhow -> Rhai runtime error -----
227 +
228 + fn rhai_err(e: impl std::fmt::Display) -> Box<EvalAltResult> {
229 + Box::new(EvalAltResult::ErrorRuntime(e.to_string().into(), rhai::Position::NONE))
230 + }
231 +
232 + /// Read `tauri.conf.json`'s version for `app`, from its checkout on the daemon
233 + /// host. Used by `version_of()` and the runner's default-version path.
234 + pub fn version_from_tauri_conf(repo: &str) -> Result<Version> {
235 + let path = expand_tilde(repo).join("src-tauri").join("tauri.conf.json");
236 + let raw = std::fs::read_to_string(&path)
237 + .with_context(|| format!("reading {}", path.display()))?;
238 + let v: serde_json::Value = serde_json::from_str(&raw).context("parsing tauri.conf.json")?;
239 + let ver = v.get("version").and_then(|x| x.as_str()).context("no `version` in tauri.conf.json")?;
240 + Version::parse(ver).map_err(|e| anyhow::anyhow!(e))
241 + }
242 +
243 + /// Expand a leading `~/` to `$HOME`. Paths in the topology are written with `~`.
244 + pub fn expand_tilde(p: &str) -> PathBuf {
245 + if let Some(rest) = p.strip_prefix("~/") {
246 + if let Ok(home) = std::env::var("HOME") {
247 + return Path::new(&home).join(rest);
248 + }
249 + }
250 + PathBuf::from(p)
251 + }
252 +
253 + /// Build a Rhai engine with the host API bound to `ctx`. Sandboxed: recipes
254 + /// touch the outside world only through these functions.
255 + pub fn build_engine(ctx: Arc<RecipeCtx>) -> Engine {
256 + let mut engine = Engine::new();
257 + // Defensive caps — recipes are first-party but bound the blast radius.
258 + engine.set_max_operations(5_000_000);
259 + engine.set_max_call_levels(64);
260 + engine.set_max_string_size(0);
261 +
262 + // --- step(name) ---
263 + {
264 + let ctx = ctx.clone();
265 + engine.register_fn("step", move |name: &str| -> Result<(), Box<EvalAltResult>> {
266 + let step: Step = name.parse().map_err(rhai_err)?;
267 + ctx.begin_step(step).map_err(rhai_err)
268 + });
269 + }
270 +
271 + // --- sh(host, cmd) -> #{ code, stdout_tail } ---
272 + {
273 + let ctx = ctx.clone();
274 + engine.register_fn("sh", move |host: &str, cmd: &str| -> Result<Map, Box<EvalAltResult>> {
275 + let (code, tail) = ctx.run(host, cmd).map_err(rhai_err)?;
276 + let mut m = Map::new();
277 + m.insert("code".into(), (code as i64).into());
278 + m.insert("stdout_tail".into(), tail.into());
279 + Ok(m)
280 + });
281 + }
282 +
283 + // --- sh_ok(host, cmd): run + assert exit 0 ---
284 + {
285 + let ctx = ctx.clone();
286 + engine.register_fn("sh_ok", move |host: &str, cmd: &str| -> Result<(), Box<EvalAltResult>> {
287 + let (code, _) = ctx.run(host, cmd).map_err(rhai_err)?;
288 + if code != 0 {
289 + return Err(rhai_err(format!("command on `{host}` exited {code}: {cmd}")));
290 + }
291 + Ok(())
292 + });
293 + }
294 +
295 + // --- log(msg): operator-visible line into the current step's tail ---
296 + {
297 + let ctx = ctx.clone();
298 + engine.register_fn("log", move |msg: &str| -> Result<(), Box<EvalAltResult>> {
299 + let sink = ctx.ensure_step().map_err(rhai_err)?;
300 + let line = format!("[recipe] {msg}\n");
301 + ctx.rt.block_on(async {
302 + use ops_core::remote::LogSink;
303 + sink.lock().await.write_chunk(line.as_bytes()).await;
304 + });
305 + Ok(())
306 + });
307 + }
308 +
309 + // --- version_of(app) -> string ---
310 + {
311 + let ctx = ctx.clone();
312 + engine.register_fn("version_of", move |app: &str| -> Result<String, Box<EvalAltResult>> {
313 + // Only the current app is in scope; cross-app reads aren't needed.
314 + if app != ctx.app.as_str() {
315 + return Err(rhai_err(format!("version_of: `{app}` is not the app being built")));
316 + }
317 + Ok(ctx.version.to_string())
318 + });
319 + }
320 +
321 + // --- secret(key) -> string (file under secrets_root; never logged) ---
322 + {
323 + let ctx = ctx.clone();
324 + engine.register_fn("secret", move |key: &str| -> Result<String, Box<EvalAltResult>> {
325 + // Guard against path traversal out of secrets_root.
326 + if key.contains("..") || key.starts_with('/') {
327 + return Err(rhai_err("secret key must be a relative path without `..`"));
328 + }
329 + let path = ctx.cfg.secrets_root.join(key);
330 + std::fs::read_to_string(&path)
331 + .map(|s| s.trim_end().to_string())
332 + .map_err(|e| rhai_err(format!("secret `{key}`: {e}")))
333 + });
334 + }
335 +
336 + // --- env(host, key) -> string ---
337 + {
338 + let ctx = ctx.clone();
339 + engine.register_fn("env", move |host: &str, key: &str| -> Result<String, Box<EvalAltResult>> {
340 + // Read via the shell so it works on remote hosts too.
341 + let (code, tail) = ctx
342 + .run(host, &format!("printf '%s' \"${{{key}}}\""))
343 + .map_err(rhai_err)?;
344 + if code != 0 {
345 + return Err(rhai_err(format!("env `{key}` on `{host}` failed")));
346 + }
347 + Ok(tail.trim().to_string())
348 + });
349 + }
350 +
351 + // --- collect(host, glob, app, version): pull artifacts to dist_root ---
352 + {
353 + let ctx = ctx.clone();
354 + engine.register_fn(
355 + "collect",
356 + move |host: &str, glob: &str, app: &str, version: &str| -> Result<(), Box<EvalAltResult>> {
357 + ctx.collect(host, glob, app, version).map_err(rhai_err)
358 + },
359 + );
360 + }
361 +
362 + // --- publish(channel, app, target, version, artifact, meta) ---
363 + {
364 + let ctx = ctx.clone();
365 + engine.register_fn(
366 + "publish",
367 + move |channel: &str, app: &str, target: &str, version: &str, artifact: &str, meta: Map| -> Result<String, Box<EvalAltResult>> {
368 + ctx.publish(channel, app, target, version, artifact, meta).map_err(rhai_err)
369 + },
370 + );
371 + }
372 +
373 + // --- macOS signing helpers (run on the named host; exercised once the Mac
374 + // keychain blocker clears — see _private/docs/bento/design.md §3/§7) ---
375 + register_macos_fns(&mut engine, &ctx);
376 +
377 + engine
378 + }
379 +
380 + impl RecipeCtx {
381 + fn collect(self: &Arc<Self>, host: &str, glob: &str, app: &str, version: &str) -> Result<()> {
382 + let dest = self.cfg.dist_root.join(app).join(version);
383 + let dest_s = dest.to_string_lossy().into_owned();
384 + let h = self.host(host)?;
385 + // The daemon does the transfer locally: cp for a local host, scp for a
386 + // remote one. (Remote glob expansion happens on the remote shell.)
387 + let cmd = if h.is_local() {
388 + let g = ops_core::remote::sh_quote(glob);
389 + let d = ops_core::remote::sh_quote(&dest_s);
390 + format!("mkdir -p {d} && cp -vR {g} {d}/")
391 + } else {
392 + let d = ops_core::remote::sh_quote(&dest_s);
393 + format!(
394 + "mkdir -p {d} && scp -r {flags} {tgt}:{glob} {d}/",
395 + flags = ops_core::remote::SSH_FLAGS.join(" "),
396 + tgt = h.ssh_target(),
397 + )
398 + };
399 + // The daemon always runs the transfer itself (local cp or local scp),
400 + // regardless of which host built the artifact.
401 + let sink = self.ensure_step()?;
402 + let local = RemoteHost::new("local");
403 + let out = self.rt.block_on(async move { local.run_streaming(&cmd, sink).await })?;
404 + if !out.success() {
405 + anyhow::bail!("collect failed (exit {:?})", out.status.code());
406 + }
407 + // Best-effort size accounting for the event.
408 + events::emit(
409 + &self.events,
410 + Event::ArtifactCollected {
411 + app: self.app.clone(),
412 + target: self.target,
413 + path: dest_s,
414 + bytes: dir_size(&dest).unwrap_or(0),
415 + },
416 + );
417 + Ok(())
418 + }
419 +
420 + fn publish(
421 + self: &Arc<Self>,
422 + channel: &str,
423 + app: &str,
424 + target: &str,
425 + version: &str,
426 + artifact: &str,
427 + meta: Map,
428 + ) -> Result<String> {
429 + let backend = self
430 + .ota
431 + .get(channel)
432 + .ok_or_else(|| anyhow::anyhow!("unknown publish channel `{channel}`"))?;
433 + let target: Target = target.parse().map_err(|e: String| anyhow::anyhow!(e))?;
434 + let version = Version::parse(version).map_err(|e| anyhow::anyhow!(e))?;
435 + let app = AppId::new(app);
436 + let notes = meta.get("notes").and_then(|v| v.clone().into_string().ok()).unwrap_or_default();
437 + // Resolve the artifact relative to the collected dist dir if not absolute.
438 + let artifact_path = {
439 + let p = PathBuf::from(artifact);
440 + if p.is_absolute() {
441 + p
442 + } else {
443 + self.cfg.dist_root.join(app.as_str()).join(version.to_string()).join(artifact)
444 + }
445 + };
446 + let rel = Release { app: &app, target, version: &version, notes };
447 + let receipt = backend
448 + .publish(&rel, &artifact_path)
449 + .with_context(|| format!("publish to `{channel}`"))?;
450 + // Record for idempotency / monotonicity.
451 + let me = self.clone();
452 + let (app_s, target_s, ver_s, chan_s) =
453 + (app.to_string(), target.to_string(), version.to_string(), channel.to_string());
454 + self.rt.block_on(async move {
455 + let _ = sqlx::query(
456 + "INSERT OR IGNORE INTO releases (app, target, version, channel, published_at)
457 + VALUES (?, ?, ?, ?, ?)",
458 + )
459 + .bind(app_s)
460 + .bind(target_s)
461 + .bind(ver_s)
462 + .bind(chan_s)
463 + .bind(Self::now())
464 + .execute(&me.pool)
465 + .await;
466 + });
467 + events::emit(
468 + &self.events,
469 + Event::PublishOk { app: self.app.clone(), target: self.target, channel: channel.to_string() },
470 + );
471 + Ok(receipt)
472 + }
473 + }
474 +
475 + fn dir_size(p: &Path) -> Option<i64> {
476 + let mut total = 0i64;
477 + for entry in std::fs::read_dir(p).ok()? {
478 + let entry = entry.ok()?;
479 + let md = entry.metadata().ok()?;
480 + if md.is_file() {
481 + total += md.len() as i64;
482 + }
483 + }
484 + Some(total)
485 + }
486 +
487 + /// macOS signing/notarization host functions. Thin wrappers over the right
488 + /// shell incantations, run on the named host. Not exercised this session (the
489 + /// Mac keychain blocker, design §3, is uncleared) but staged so the macOS
490 + /// recipe runs unmodified once it is.
491 + fn register_macos_fns(engine: &mut Engine, ctx: &Arc<RecipeCtx>) {
492 + {
493 + let ctx = ctx.clone();
494 + engine.register_fn(
495 + "verify_gatekeeper",
496 + move |host: &str, path: &str| -> Result<bool, Box<EvalAltResult>> {
497 + let (_, tail) = ctx
498 + .run(host, &format!("spctl --assess -vv --type install {} 2>&1 || true", ops_core::remote::sh_quote(path)))
499 + .map_err(rhai_err)?;
500 + Ok(tail.contains("source=Notarized Developer ID"))
Lines truncated
@@ -0,0 +1,27 @@
1 + use axum::http::StatusCode;
2 + use axum::response::{IntoResponse, Response};
3 +
4 + #[derive(Debug, thiserror::Error)]
5 + pub enum Error {
6 + #[error("not found")]
7 + NotFound,
8 + #[error("bad request: {0}")]
9 + BadRequest(String),
10 + #[error(transparent)]
11 + Db(#[from] sqlx::Error),
12 + #[error(transparent)]
13 + Other(#[from] anyhow::Error),
14 + }
15 +
16 + impl IntoResponse for Error {
17 + fn into_response(self) -> Response {
18 + let status = match &self {
19 + Error::NotFound => StatusCode::NOT_FOUND,
20 + Error::BadRequest(_) => StatusCode::BAD_REQUEST,
21 + _ => StatusCode::INTERNAL_SERVER_ERROR,
22 + };
23 + (status, self.to_string()).into_response()
24 + }
25 + }
26 +
27 + pub type Result<T> = std::result::Result<T, Error>;
@@ -0,0 +1,43 @@
1 + //! Bento's concrete event payload, carried on the generic
2 + //! [`ops_core::eventbus`] bus. Flat `kind`-tagged so it serializes to the wire
3 + //! shape the TUI parses (`{"kind":"step_start", ...}`).
4 +
5 + use crate::domain::{AppId, Status, Step, StepRunId, Target, Version};
6 + use ops_core::eventbus;
7 + use serde::{Deserialize, Serialize};
8 +
9 + pub type EventEnvelope = eventbus::EventEnvelope<Event>;
10 + pub type EventTx = eventbus::EventTx<Event>;
11 +
12 + pub fn channel() -> EventTx {
13 + eventbus::channel()
14 + }
15 +
16 + pub fn emit(tx: &EventTx, event: Event) {
17 + eventbus::emit(tx, event)
18 + }
19 +
20 + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
21 + #[serde(tag = "kind", rename_all = "snake_case")]
22 + pub enum Event {
23 + /// A `/build` was accepted.
24 + BuildRequested { app: AppId, version: Version, targets: Vec<Target> },
25 + /// A previous in-flight run for this `(app, target)` was aborted because a
26 + /// newer request arrived.
27 + TargetAborted { app: AppId, target: Target },
28 + TargetStart { app: AppId, version: Version, target: Target },
29 + StepStart { run_id: StepRunId, app: AppId, version: Version, target: Target, step: Step },
30 + /// A chunk of merged stdout+stderr from the step currently running.
31 + /// `run_id` ties back to the `StepStart`; `seq` is a per-run monotonic
32 + /// counter; `text` is UTF-8-lossy and NOT line-aligned. The on-disk log is
33 + /// the byte-exact source.
34 + StepLogChunk { run_id: StepRunId, seq: u32, text: String },
35 + StepDone { run_id: StepRunId, app: AppId, target: Target, step: Step, status: Status },
36 + TargetOk { app: AppId, version: Version, target: Target, artifacts: Vec<String> },
37 + TargetFailed { app: AppId, version: Version, target: Target, step: Step, error: String },
38 + /// Notarization is the one flaky, network-bound step; retries are surfaced.
39 + NotarizeRetry { app: AppId, target: Target, attempt: u32, reason: String },
40 + ArtifactCollected { app: AppId, target: Target, path: String, bytes: i64 },
41 + PublishOk { app: AppId, target: Target, channel: String },
42 + PublishFailed { app: AppId, target: Target, channel: String, error: String },
43 + }
@@ -0,0 +1,19 @@
1 + //! bento-daemon as a library.
2 + //!
3 + //! Exposes every module so the `bentod` binary (`src/main.rs`) and the `bento`
4 + //! TUI (`../tui`) share wire-facing types — events, domain newtypes — by import
5 + //! rather than duplication. External consumers mainly need `domain` and
6 + //! `events`.
7 +
8 + pub mod config;
9 + pub mod db;
10 + pub mod domain;
11 + pub mod engine;
12 + pub mod error;
13 + pub mod events;
14 + pub mod metrics;
15 + pub mod ota;
16 + pub mod routes;
17 + pub mod runner;
18 + pub mod state;
19 + pub mod topology;
@@ -0,0 +1,46 @@
1 + use anyhow::Result;
2 + use bento_daemon::{config, db, events, metrics, ota, routes, state, topology};
3 + use std::collections::HashMap;
4 + use std::net::SocketAddr;
5 + use std::sync::Arc;
6 +
7 + /// MNW base URL the `tauri-mnw` OTA backend publishes to. Overridable so a
8 + /// staging host can point elsewhere; defaults to production.
9 + fn mnw_base_url() -> String {
10 + std::env::var("BENTO_MNW_BASE_URL").unwrap_or_else(|_| "https://makenot.work".into())
11 + }
12 +
13 + #[tokio::main]
14 + async fn main() -> Result<()> {
15 + tracing_subscriber::fmt()
16 + .with_writer(std::io::stderr)
17 + .with_env_filter(
18 + tracing_subscriber::EnvFilter::try_from_default_env()
19 + .unwrap_or_else(|_| "bento_daemon=info,bentod=info,tower_http=info".into()),
20 + )
21 + .init();
22 +
23 + let cfg = Arc::new(config::Config::load()?);
24 + let topo = Arc::new(topology::Topology::load(&cfg.topology_path)?);
25 + tokio::fs::create_dir_all(&cfg.dist_root).await?;
26 + tokio::fs::create_dir_all(&cfg.logs_root).await?;
27 + let pool = db::open(&cfg.db_path).await?;
28 + tracing::info!(hosts = topo.hosts.len(), apps = topo.app.len(), "topology loaded");
29 +
30 + let prom = metrics::init();
31 + let addr: SocketAddr = cfg.listen.parse()?;
32 + let app_state = state::AppState {
33 + pool,
34 + topo,
35 + cfg,
36 + prom,
37 + events: events::channel(),
38 + ota: Arc::new(ota::OtaRegistry::standard(mnw_base_url())),
39 + active: Arc::new(tokio::sync::Mutex::new(HashMap::new())),
40 + };
41 + let app = routes::router(app_state);
42 + tracing::info!(%addr, "bento daemon listening");
43 + let listener = tokio::net::TcpListener::bind(addr).await?;
44 + axum::serve(listener, app).await?;
45 + Ok(())
46 + }
@@ -0,0 +1,19 @@
1 + use axum::{extract::State, response::IntoResponse};
2 + use metrics_exporter_prometheus::{PrometheusBuilder, PrometheusHandle};
3 +
4 + pub fn init() -> PrometheusHandle {
5 + PrometheusBuilder::new().install_recorder().expect("install prometheus recorder")
6 + }
7 +
8 + pub async fn render(State(handle): State<PrometheusHandle>) -> impl IntoResponse {
9 + handle.render()
10 + }
11 +
12 + /// A render handle that does NOT install the global recorder — the global is a
13 + /// process-wide singleton, so tests (many `AppState`s per process) must not
14 + /// call [`init`]. Metrics emitted via the macros are dropped under this handle,
15 + /// which is fine for tests that only exercise routes.
16 + #[cfg(test)]
17 + pub fn test_handle() -> PrometheusHandle {
18 + PrometheusBuilder::new().build_recorder().handle()
19 + }
@@ -0,0 +1,128 @@
1 + //! Release-channel abstraction.
2 + //!
3 + //! The publish step is not hardcoded to one updater or store. A recipe calls
4 + //! `publish("<channel>", ...)`; the daemon dispatches to the named
5 + //! [`OtaBackend`]. The flaky/lying-exit-code lessons (altool exits 0 on
6 + //! failure; notarytool needs retry) get encoded inside the backends so every
7 + //! recipe inherits them.
8 + //!
9 + //! P1 status: the trait, the registry, and a `tauri-mnw` backend skeleton are
10 + //! here so recipes can name a channel and the wiring is exercised end-to-end.
11 + //! The actual artifact upload to the MNW OTA API (`server/routes/ota.rs`) is
12 + //! P2 — [`TauriMnwBackend::publish`] currently performs the guardrail checks
13 + //! and returns a descriptive receipt without transferring bytes.
14 +
15 + use crate::domain::{AppId, Target, Version};
16 + use anyhow::Result;
17 + use std::collections::HashMap;
18 + use std::path::Path;
19 +
20 + /// One signed artifact ready to publish for `(app, target, version)`.
21 + pub struct Release<'a> {
22 + pub app: &'a AppId,
23 + pub target: Target,
24 + pub version: &'a Version,
25 + pub notes: String,
26 + }
27 +
28 + /// A delivery system. Adding one (`testflight`, `play`, `static-manifest`,
29 + /// `github-releases`, …) is a single `impl` plus registering its id; recipes
30 + /// don't change.
31 + pub trait OtaBackend: Send + Sync {
32 + fn id(&self) -> &str;
33 + fn supports(&self, target: Target) -> bool;
34 + /// Publish one artifact; return a channel-specific receipt string. Must be
35 + /// idempotent at the backend boundary (re-publishing the same
36 + /// version+artifact is a no-op, not a duplicate release).
37 + fn publish(&self, rel: &Release, artifact: &Path) -> Result<String>;
38 + }
39 +
40 + /// Named lookup of registered backends.
41 + #[derive(Default)]
42 + pub struct OtaRegistry {
43 + backends: HashMap<String, Box<dyn OtaBackend>>,
44 + }
45 +
46 + impl OtaRegistry {
47 + pub fn new() -> Self {
48 + Self::default()
49 + }
50 +
51 + pub fn register(&mut self, backend: Box<dyn OtaBackend>) {
52 + self.backends.insert(backend.id().to_string(), backend);
53 + }
54 +
55 + pub fn get(&self, channel: &str) -> Option<&dyn OtaBackend> {
56 + self.backends.get(channel).map(|b| b.as_ref())
57 + }
58 +
59 + /// The standard registry: `tauri-mnw` wired to the MNW OTA endpoint.
60 + pub fn standard(mnw_base_url: impl Into<String>) -> Self {
61 + let mut reg = Self::new();
62 + reg.register(Box::new(TauriMnwBackend { base_url: mnw_base_url.into() }));
63 + reg
64 + }
65 + }
66 +
67 + /// Hook into Tauri's OTA system via the MNW server (the manifest host +
68 + /// release registry). The Tauri updater polls
69 + /// `GET /api/v1/sync/ota/{slug}/{target}/{arch}/{current}` and verifies a
70 + /// minisign signature; this backend registers the release + uploads the signed
71 + /// `*.app.tar.gz` (+ its `.sig`) so that endpoint serves the right manifest.
72 + pub struct TauriMnwBackend {
73 + pub base_url: String,
74 + }
75 +
76 + impl OtaBackend for TauriMnwBackend {
77 + fn id(&self) -> &str {
78 + "tauri-mnw"
79 + }
80 +
81 + fn supports(&self, target: Target) -> bool {
82 + use crate::domain::Platform::*;
83 + // Tauri's updater covers the desktop trio; mobile rides testflight/play.
84 + matches!(target.platform, Macos | Linux | Windows)
85 + }
86 +
87 + fn publish(&self, rel: &Release, artifact: &Path) -> Result<String> {
88 + // Guardrail: never publish an artifact that isn't there.
89 + anyhow::ensure!(artifact.exists(), "artifact {} does not exist", artifact.display());
90 + // P2: POST the release + upload the artifact + its minisign .sig to the
91 + // MNW OTA API at `self.base_url`. Until then, report what would happen
92 + // so the recipe path is exercised without a half-published release.
93 + Ok(format!(
94 + "tauri-mnw: would publish {app} {ver} {target} ({artifact}) to {base} [P2: upload not yet wired]",
95 + app = rel.app,
96 + ver = rel.version,
97 + target = rel.target,
98 + artifact = artifact.display(),
99 + base = self.base_url,
100 + ))
101 + }
102 + }
103 +
104 + #[cfg(test)]
105 + mod tests {
106 + use super::*;
107 +
108 + #[test]
109 + fn standard_registry_has_tauri_mnw() {
110 + let reg = OtaRegistry::standard("https://makenot.work");
111 + let b = reg.get("tauri-mnw").expect("registered");
112 + assert_eq!(b.id(), "tauri-mnw");
113 + assert!(b.supports("macos/aarch64".parse().unwrap()));
114 + assert!(b.supports("linux/x86_64".parse().unwrap()));
115 + assert!(!b.supports("ios/universal".parse().unwrap()));
116 + assert!(reg.get("nope").is_none());
117 + }
118 +
119 + #[test]
120 + fn publish_refuses_missing_artifact() {
121 + let reg = OtaRegistry::standard("https://makenot.work");
122 + let b = reg.get("tauri-mnw").unwrap();
123 + let app = AppId::new("goingson");
124 + let ver = Version::parse("0.4.1").unwrap();
125 + let rel = Release { app: &app, target: "macos/aarch64".parse().unwrap(), version: &ver, notes: String::new() };
126 + assert!(b.publish(&rel, Path::new("/no/such/file")).is_err());
127 + }
128 + }
@@ -0,0 +1,317 @@
1 + //! HTTP + WS contract (mirrors Sando's shape).
2 + //!
3 + //! - `GET /state` — latest build + its target x step matrix (loose JSON).
4 + //! - `POST /build` — `{app, version?, targets?[]}` kick a build.
5 + //! - `POST /retry` — `{app, target, version?}` re-run one target.
6 + //! - `GET /logs/{app}/{version}/{target}/{step}` — post-mortem step log.
7 + //! - `GET /events` — WS; broadcasts `EventEnvelope` JSON frames.
8 + //! - `GET /metrics` — Prometheus.
9 +
10 + use crate::domain::{AppId, Target};
11 + use crate::error::{Error, Result};
12 + use crate::runner;
13 + use crate::state::AppState;
14 + use axum::extract::{Path, State, WebSocketUpgrade};
15 + use axum::response::IntoResponse;
16 + use axum::routing::{get, post};
17 + use axum::{Json, Router};
18 + use serde::{Deserialize, Serialize};
19 + use sqlx::Row;
20 +
21 + pub fn router(state: AppState) -> Router {
22 + let prom = state.prom.clone();
23 + Router::new()
24 + .route("/state", get(get_state))
25 + .route("/build", post(build))
26 + .route("/retry", post(retry))
27 + .route("/logs/{app}/{version}/{target}/{step}", get(get_step_log))
28 + .route("/events", get(events_ws))
29 + .with_state(state)
30 + .route("/metrics", get(crate::metrics::render).with_state(prom))
31 + }
32 +
33 + #[derive(Serialize)]
34 + struct StateView {
35 + build: Option<BuildView>,
36 + }
37 +
38 + #[derive(Serialize)]
39 + struct BuildView {
40 + id: i64,
41 + app: String,
42 + version: String,
43 + status: String,
44 + created_at: String,
45 + targets: Vec<TargetView>,
46 + }
47 +
48 + #[derive(Serialize)]
49 + struct TargetView {
50 + target: String,
51 + status: String,
52 + current_step: Option<String>,
53 + error: Option<String>,
54 + steps: Vec<StepView>,
55 + }
56 +
57 + #[derive(Serialize)]
58 + struct StepView {
59 + run_id: i64,
60 + step: String,
61 + status: String,
62 + log_ref: Option<String>,
63 + }
64 +
65 + async fn get_state(State(s): State<AppState>) -> Result<Json<StateView>> {
66 + let latest = sqlx::query(
67 + "SELECT id, app, version, status, created_at FROM builds ORDER BY id DESC LIMIT 1",
68 + )
69 + .fetch_optional(&s.pool)
70 + .await?;
71 +
72 + let Some(b) = latest else {
73 + return Ok(Json(StateView { build: None }));
74 + };
75 + let build_id: i64 = b.get("id");
76 +
77 + let target_rows = sqlx::query(
78 + "SELECT id, target, status, current_step, error FROM target_runs
79 + WHERE build_id = ? ORDER BY target",
80 + )
81 + .bind(build_id)
82 + .fetch_all(&s.pool)
83 + .await?;
84 +
85 + let mut targets = Vec::with_capacity(target_rows.len());
86 + for tr in target_rows {
87 + let target_run_id: i64 = tr.get("id");
88 + // Latest row per step for this target run.
89 + let steps: Vec<StepView> = sqlx::query(
90 + "SELECT id, step, status, log_ref FROM step_runs sr
91 + WHERE target_run_id = ?1
92 + AND id = (SELECT MAX(id) FROM step_runs
93 + WHERE target_run_id = ?1 AND step = sr.step)
94 + ORDER BY id",
95 + )
96 + .bind(target_run_id)
97 + .fetch_all(&s.pool)
98 + .await?
99 + .into_iter()
100 + .map(|r| StepView {
101 + run_id: r.get("id"),
102 + step: r.get("step"),
103 + status: r.get("status"),
104 + log_ref: r.get("log_ref"),
105 + })
106 + .collect();
107 +
108 + targets.push(TargetView {
109 + target: tr.get("target"),
110 + status: tr.get("status"),
111 + current_step: tr.get("current_step"),
112 + error: tr.get("error"),
113 + steps,
114 + });
115 + }
116 +
117 + Ok(Json(StateView {
118 + build: Some(BuildView {
119 + id: build_id,
120 + app: b.get("app"),
121 + version: b.get("version"),
122 + status: b.get("status"),
123 + created_at: b.get("created_at"),
124 + targets,
125 + }),
126 + }))
127 + }
128 +
129 + #[derive(Deserialize, Default)]
130 + struct BuildBody {
131 + app: String,
132 + #[serde(default)]
133 + version: Option<String>,
134 + #[serde(default)]
135 + targets: Vec<String>,
136 + }
137 +
138 + async fn build(State(s): State<AppState>, Json(body): Json<BuildBody>) -> Result<Json<serde_json::Value>> {
139 + let app = AppId::new(body.app);
140 + let targets = parse_targets(body.targets)?;
141 + let version = runner::resolve_version(&s, &app, body.version).map_err(Error::Other)?;
142 + let targets = runner::resolve_targets(&s, &app, targets).map_err(Error::Other)?;
143 + let build_id = runner::start_build(s, app, version.clone(), targets)
144 + .await
145 + .map_err(Error::Other)?;
146 + Ok(Json(serde_json::json!({ "accepted": true, "build_id": build_id, "version": version.to_string() })))
147 + }
148 +
149 + #[derive(Deserialize)]
150 + struct RetryBody {
151 + app: String,
152 + target: String,
153 + #[serde(default)]
154 + version: Option<String>,
155 + }
156 +
157 + async fn retry(State(s): State<AppState>, Json(body): Json<RetryBody>) -> Result<Json<serde_json::Value>> {
158 + let app = AppId::new(body.app);
159 + let target: Target = body.target.parse().map_err(Error::BadRequest)?;
160 + let version = runner::resolve_version(&s, &app, body.version).map_err(Error::Other)?;
161 + let targets = runner::resolve_targets(&s, &app, vec![target]).map_err(Error::Other)?;
162 + let build_id = runner::start_build(s, app, version.clone(), targets)
163 + .await
164 + .map_err(Error::Other)?;
165 + Ok(Json(serde_json::json!({ "accepted": true, "build_id": build_id, "target": target.to_string() })))
166 + }
167 +
168 + fn parse_targets(raw: Vec<String>) -> Result<Vec<Target>> {
169 + raw.into_iter().map(|t| t.parse::<Target>().map_err(Error::BadRequest)).collect()
170 + }
171 +
172 + async fn get_step_log(
173 + State(s): State<AppState>,
174 + Path((app, version, target, step)): Path<(String, String, String, String)>,
175 + ) -> Result<axum::response::Response> {
176 + fn safe(seg: &str) -> bool {
177 + !seg.is_empty() && !seg.contains('/') && !seg.contains('\\') && seg != "." && seg != ".."
178 + }
179 + if ![&app, &version, &target, &step].into_iter().all(|s| safe(s)) {
180 + return Err(Error::NotFound);
181 + }
182 + let path = s.cfg.logs_root.join(&app).join(&version).join(&target).join(format!("{step}.log"));
183 + match tokio::fs::read(&path).await {
184 + Ok(bytes) => Ok((
185 + [(axum::http::header::CONTENT_TYPE, "text/plain; charset=utf-8")],
186 + bytes,
187 + )
188 + .into_response()),
189 + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Err(Error::NotFound),
190 + Err(e) => Err(Error::Other(e.into())),
191 + }
192 + }
193 +
194 + async fn events_ws(ws: WebSocketUpgrade, State(s): State<AppState>) -> impl IntoResponse {
195 + use axum::extract::ws::Message;
196 + use tokio::sync::broadcast::error::RecvError;
197 +
198 + ws.on_upgrade(move |mut socket| async move {
199 + let mut rx = s.events.subscribe();
200 + loop {
201 + match rx.recv().await {
202 + Ok(env) => {
203 + let json = match serde_json::to_string(&env) {
204 + Ok(s) => s,
205 + Err(e) => {
206 + tracing::warn!(error = %e, "events ws: serialize failed");
207 + continue;
208 + }
209 + };
210 + if socket.send(Message::Text(json.into())).await.is_err() {
211 + break;
212 + }
213 + }
214 + Err(RecvError::Lagged(n)) => {
215 + let _ = socket
216 + .send(Message::Text(format!(r#"{{"kind":"lagged","skipped":{n}}}"#).into()))
217 + .await;
218 + }
219 + Err(RecvError::Closed) => break,
220 + }
221 + }
222 + })
223 + }
224 +
225 + #[cfg(test)]
226 + mod tests {
227 + use super::*;
228 + use crate::config::Config;
229 + use crate::ota::OtaRegistry;
230 + use crate::topology::Topology;
231 + use axum::body::Body;
232 + use axum::http::{Request, StatusCode};
233 + use http_body_util::BodyExt;
234 + use std::collections::HashMap;
235 + use std::sync::Arc;
236 + use tokio::sync::Mutex;
237 + use tower::ServiceExt;
238 +
239 + async fn test_state(root: &std::path::Path) -> AppState {
240 + let cfg = Config::for_tests(root);
241 + let pool = crate::db::open(&cfg.db_path).await.unwrap();
242 + let topo: Topology = toml::from_str(
243 + r#"
244 + [[host]]
245 + name = "fw13"
246 + ssh = "local"
247 + targets = ["linux/x86_64"]
248 +
249 + [app.goingson]
250 + repo = "/tmp/none"
251 + targets = ["linux/x86_64"]
252 + "#,
253 + )
254 + .unwrap();
255 + AppState {
256 + pool,
257 + topo: Arc::new(topo),
258 + cfg: Arc::new(cfg),
259 + prom: crate::metrics::test_handle(),
260 + events: crate::events::channel(),
261 + ota: Arc::new(OtaRegistry::standard("https://makenot.work")),
262 + active: Arc::new(Mutex::new(HashMap::new())),
263 + }
264 + }
265 +
266 + async fn body_string(resp: axum::response::Response) -> String {
267 + let bytes = resp.into_body().collect().await.unwrap().to_bytes();
268 + String::from_utf8(bytes.to_vec()).unwrap()
269 + }
270 +
271 + #[tokio::test]
272 + async fn state_is_empty_initially() {
273 + let tmp = tempfile::tempdir().unwrap();
274 + let app = router(test_state(tmp.path()).await);
275 + let resp = app
276 + .oneshot(Request::builder().uri("/state").body(Body::empty()).unwrap())
277 + .await
278 + .unwrap();
279 + assert_eq!(resp.status(), StatusCode::OK);
280 + assert_eq!(body_string(resp).await, r#"{"build":null}"#);
281 + }
282 +
283 + #[tokio::test]
284 + async fn step_log_rejects_traversal() {
285 + let tmp = tempfile::tempdir().unwrap();
286 + let app = router(test_state(tmp.path()).await);
287 + let resp = app
288 + .oneshot(
289 + Request::builder()
290 + .uri("/logs/goingson/0.4.1/..%2f..%2fetc/passwd")
291 + .body(Body::empty())
292 + .unwrap(),
293 + )
294 + .await
295 + .unwrap();
296 + assert_eq!(resp.status(), StatusCode::NOT_FOUND);
297 + }
298 +
299 + #[tokio::test]
300 + async fn build_rejects_unknown_target() {
301 + let tmp = tempfile::tempdir().unwrap();
302 + let app = router(test_state(tmp.path()).await);
303 + let resp = app
304 + .oneshot(
305 + Request::builder()
306 + .method("POST")
307 + .uri("/build")
308 + .header("content-type", "application/json")
309 + .body(Body::from(r#"{"app":"goingson","targets":["windows/x86_64"]}"#))
310 + .unwrap(),
311 + )
312 + .await
313 + .unwrap();
314 + // windows/x86_64 isn't shipped by the test app -> 500 (Other).
315 + assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
316 + }
317 + }
@@ -0,0 +1,360 @@
1 + //! Build orchestration: fan a `(app, version)` out across its targets, each
2 + //! running its recipe concurrently on the host that can build it.
3 + //!
4 + //! A build inserts one `builds` row, then spawns one task per target. Each
5 + //! target task registers itself in the single-slot guard (a newer build for
6 + //! the same `(app, target)` aborts the in-flight one — latest wins, but other
7 + //! targets keep running, which is the fan-out), then runs the recipe.
8 +
9 + use crate::domain::{AppId, Status, Step, Target, Version};
10 + use crate::engine::{self, RecipeCtx};
11 + use crate::events::{self, Event};
12 + use crate::state::AppState;
13 + use anyhow::{Context, Result};
14 + use std::path::PathBuf;
15 + use std::sync::Arc;
16 +
17 + /// Resolve the version to build: explicit, or read from `tauri.conf.json`.
18 + pub fn resolve_version(state: &AppState, app: &AppId, explicit: Option<String>) -> Result<Version> {
19 + if let Some(v) = explicit {
20 + return Version::parse(&v).map_err(|e| anyhow::anyhow!(e));
21 + }
22 + let cfg = state.topo.app(app).ok_or_else(|| anyhow::anyhow!("unknown app `{app}`"))?;
23 + engine::version_from_tauri_conf(&cfg.repo)
24 + }
25 +
26 + /// Validate + default the target list against what the app ships.
27 + pub fn resolve_targets(state: &AppState, app: &AppId, requested: Vec<Target>) -> Result<Vec<Target>> {
28 + let cfg = state.topo.app(app).ok_or_else(|| anyhow::anyhow!("unknown app `{app}`"))?;
29 + if requested.is_empty() {
30 + return Ok(cfg.targets.clone());
31 + }
32 + for t in &requested {
33 + anyhow::ensure!(cfg.targets.contains(t), "app `{app}` does not ship target {t}");
34 + anyhow::ensure!(state.topo.host_for(*t).is_some(), "no host can build {t}");
35 + }
36 + Ok(requested)
37 + }
38 +
39 + /// Insert the build row and spawn per-target tasks. Returns the build id.
40 + pub async fn start_build(
41 + state: AppState,
42 + app: AppId,
43 + version: Version,
44 + targets: Vec<Target>,
45 + ) -> Result<i64> {
46 + let build_id: i64 = sqlx::query_scalar(
47 + "INSERT INTO builds (app, version, status, created_at) VALUES (?, ?, 'running', ?) RETURNING id",
48 + )
49 + .bind(app.as_str())
50 + .bind(version.to_string())
51 + .bind(chrono::Utc::now().to_rfc3339())
52 + .fetch_one(&state.pool)
53 + .await
54 + .context("insert build")?;
55 +
56 + events::emit(
57 + &state.events,
58 + Event::BuildRequested { app: app.clone(), version: version.clone(), targets: targets.clone() },
59 + );
60 +
61 + for target in targets {
62 + let state = state.clone();
63 + let app = app.clone();
64 + let version = version.clone();
65 + let key = (app.clone(), target);
66 +
67 + // Latest-wins: abort any in-flight run for this (app, target).
68 + {
69 + let mut active = state.active.lock().await;
70 + if let Some(prev) = active.remove(&key) {
71 + if !prev.is_finished() {
72 + events::emit(
73 + &state.events,
74 + Event::TargetAborted { app: app.clone(), target },
75 + );
76 + prev.abort();
77 + }
78 + }
79 + }
80 +
81 + let handle = tokio::spawn(run_target(state.clone(), build_id, app, version, target));
82 + state.active.lock().await.insert(key, handle.abort_handle());
83 + }
84 +
85 + // Mark the build done once all target tasks settle. Spawned so /build
86 + // returns immediately.
87 + tokio::spawn(finalize_build(state, build_id));
88 + Ok(build_id)
89 + }
90 +
91 + /// Run one target's recipe end to end, updating its `target_runs` row.
92 + async fn run_target(state: AppState, build_id: i64, app: AppId, version: Version, target: Target) {
93 + let target_run_id: i64 = match sqlx::query_scalar(
94 + "INSERT INTO target_runs (build_id, app, version, target, status, started_at)
95 + VALUES (?, ?, ?, ?, 'running', ?) RETURNING id",
96 + )
97 + .bind(build_id)
98 + .bind(app.as_str())
99 + .bind(version.to_string())
100 + .bind(target.to_string())
101 + .bind(chrono::Utc::now().to_rfc3339())
102 + .fetch_one(&state.pool)
103 + .await
104 + {
105 + Ok(id) => id,
106 + Err(e) => {
107 + tracing::error!(%app, %target, error = %e, "could not create target_run");
108 + return;
109 + }
110 + };
111 +
112 + events::emit(
113 + &state.events,
114 + Event::TargetStart { app: app.clone(), version: version.clone(), target },
115 + );
116 +
117 + let recipe_src = match read_recipe(&state, &app, target) {
118 + Ok(s) => s,
119 + Err(e) => {
120 + fail_target(&state, target_run_id, &app, &version, target, Step::Checkout, &format!("{e:#}")).await;
121 + return;
122 + }
123 + };
124 +
125 + let ctx = Arc::new(RecipeCtx::new(
126 + app.clone(),
127 + version.clone(),
128 + target,
129 + target_run_id,
130 + state.topo.remote_hosts(),
131 + state.pool.clone(),
132 + state.events.clone(),
133 + state.cfg.clone(),
134 + state.ota.clone(),
135 + tokio::runtime::Handle::current(),
136 + ));
137 +
138 + // Rhai is synchronous; run the recipe (and its final step finalization) on
139 + // a blocking thread so host functions can `block_on` without sitting on a
140 + // runtime worker.
141 + let ctx_run = ctx.clone();
142 + let outcome = tokio::task::spawn_blocking(move || {
143 + let engine = engine::build_engine(ctx_run.clone());
144 + let res = engine.run(&recipe_src);
145 + let last_step = ctx_run.current_step();
146 + match &res {
147 + Ok(_) => {
148 + let _ = ctx_run.finish_step(Status::Ok);
149 + }
150 + Err(_) => {
151 + let _ = ctx_run.finish_step(Status::Failed);
152 + }
153 + }
154 + res.map(|_| ()).map_err(|e| (last_step, e.to_string()))
155 + })
156 + .await;
157 +
158 + match outcome {
159 + Ok(Ok(())) => {
160 + let artifacts = collected_artifacts(&state, &app, &version);
161 + let _ = sqlx::query(
162 + "UPDATE target_runs SET status = 'ok', current_step = NULL, finished_at = ? WHERE id = ?",
163 + )
164 + .bind(chrono::Utc::now().to_rfc3339())
165 + .bind(target_run_id)
166 + .execute(&state.pool)
167 + .await;
168 + events::emit(&state.events, Event::TargetOk { app, version, target, artifacts });
169 + }
170 + Ok(Err((step, msg))) => {
171 + fail_target(&state, target_run_id, &app, &version, target, step, &msg).await;
172 + }
173 + Err(join_err) => {
174 + // Task was aborted (superseded) or panicked.
175 + let msg = if join_err.is_cancelled() { "aborted (superseded)".to_string() } else { format!("recipe task panicked: {join_err}") };
176 + fail_target(&state, target_run_id, &app, &version, target, Step::Build, &msg).await;
177 + }
178 + }
179 + }
180 +
181 + async fn fail_target(
182 + state: &AppState,
183 + target_run_id: i64,
184 + app: &AppId,
185 + version: &Version,
186 + target: Target,
187 + step: Step,
188 + error: &str,
189 + ) {
190 + let _ = sqlx::query(
191 + "UPDATE target_runs SET status = 'failed', error = ?, finished_at = ? WHERE id = ?",
192 + )
193 + .bind(error)
194 + .bind(chrono::Utc::now().to_rfc3339())
195 + .bind(target_run_id)
196 + .execute(&state.pool)
197 + .await;
198 + events::emit(
199 + &state.events,
200 + Event::TargetFailed { app: app.clone(), version: version.clone(), target, step, error: error.to_string() },
201 + );
202 + }
203 +
204 + /// Wait for all target runs of a build to leave `running`, then stamp the
205 + /// build's terminal status.
206 + async fn finalize_build(state: AppState, build_id: i64) {
207 + loop {
208 + let running: i64 = sqlx::query_scalar(
209 + "SELECT COUNT(*) FROM target_runs WHERE build_id = ? AND status = 'running'",
210 + )
211 + .bind(build_id)
212 + .fetch_one(&state.pool)
213 + .await
214 + .unwrap_or(0);
215 + if running == 0 {
216 + break;
217 + }
218 + tokio::time::sleep(std::time::Duration::from_millis(500)).await;
219 + }
220 + let failed: i64 = sqlx::query_scalar(
221 + "SELECT COUNT(*) FROM target_runs WHERE build_id = ? AND status = 'failed'",
222 + )
223 + .bind(build_id)
224 + .fetch_one(&state.pool)
225 + .await
226 + .unwrap_or(0);
227 + let status = if failed == 0 { "ok" } else { "failed" };
228 + let _ = sqlx::query("UPDATE builds SET status = ?, finished_at = ? WHERE id = ?")
229 + .bind(status)
230 + .bind(chrono::Utc::now().to_rfc3339())
231 + .bind(build_id)
232 + .execute(&state.pool)
233 + .await;
234 + }
235 +
236 + /// Read the recipe text for `(app, target)` from the app's checkout on the
237 + /// daemon host. `<repo>/<recipe_dir>/<platform>.rhai`.
238 + fn read_recipe(state: &AppState, app: &AppId, target: Target) -> Result<String> {
239 + let cfg = state.topo.app(app).ok_or_else(|| anyhow::anyhow!("unknown app `{app}`"))?;
240 + let path: PathBuf = engine::expand_tilde(&cfg.repo)
241 + .join(&cfg.recipe_dir)
242 + .join(format!("{}.rhai", target.platform.as_str()));
243 + std::fs::read_to_string(&path).with_context(|| format!("reading recipe {}", path.display()))
244 + }
245 +
246 + fn collected_artifacts(state: &AppState, app: &AppId, version: &Version) -> Vec<String> {
247 + let dir = state.cfg.dist_root.join(app.as_str()).join(version.to_string());
248 + let Ok(rd) = std::fs::read_dir(&dir) else { return Vec::new() };
249 + rd.filter_map(|e| e.ok()).map(|e| e.file_name().to_string_lossy().into_owned()).collect()
250 + }
251 +
252 + #[cfg(test)]
253 + mod tests {
254 + use super::*;
255 + use crate::config::Config;
256 + use crate::ota::OtaRegistry;
257 + use crate::topology::Topology;
258 + use std::collections::HashMap;
259 + use std::sync::Arc;
260 + use tokio::sync::Mutex;
261 +
262 + /// Stand up a tmp app repo + topology and run a real local recipe end to
263 + /// end: step transitions, streamed `sh_ok`, `version_of`, `log`, and a
264 + /// `collect` that pulls a built artifact into dist_root.
265 + #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
266 + async fn local_linux_recipe_runs_end_to_end() {
267 + let tmp = tempfile::tempdir().unwrap();
268 + let root = tmp.path();
269 +
270 + // Fake app checkout: tauri.conf.json + a linux recipe.
271 + let repo = root.join("app");
272 + std::fs::create_dir_all(repo.join("src-tauri")).unwrap();
273 + std::fs::write(
274 + repo.join("src-tauri/tauri.conf.json"),
275 + r#"{"version":"0.0.1"}"#,
276 + )
277 + .unwrap();
278 + std::fs::create_dir_all(repo.join("dist/recipes")).unwrap();
279 + // Build writes an artifact into the repo; collect pulls it to dist_root.
280 + std::fs::write(
281 + repo.join("dist/recipes/linux.rhai"),
282 + r#"
283 + step("build");
284 + let v = version_of("demo");
285 + log("building demo " + v);
286 + sh_ok("fw13", "echo compiling; mkdir -p REPO/out && echo bin > REPO/out/demo.bin");
287 + step("collect");
288 + collect("fw13", "REPO/out/demo.bin", "demo", v);
289 + "#
290 + .replace("REPO", repo.to_str().unwrap()),
291 + )
292 + .unwrap();
293 +
294 + let cfg = Config::for_tests(root);
295 + let pool = crate::db::open(&cfg.db_path).await.unwrap();
296 + let topo: Topology = toml::from_str(&format!(
297 + r#"
298 + [[host]]
299 + name = "fw13"
300 + ssh = "local"
301 + targets = ["linux/x86_64"]
302 +
303 + [app.demo]
304 + repo = "{}"
305 + targets = ["linux/x86_64"]
306 + "#,
307 + repo.display()
308 + ))
309 + .unwrap();
310 +
311 + let state = AppState {
312 + pool: pool.clone(),
313 + topo: Arc::new(topo),
314 + cfg: Arc::new(cfg),
315 + prom: crate::metrics::test_handle(),
316 + events: crate::events::channel(),
317 + ota: Arc::new(OtaRegistry::standard("https://makenot.work")),
318 + active: Arc::new(Mutex::new(HashMap::new())),
319 + };
320 +
321 + let app = AppId::new("demo");
322 + let version = Version::parse("0.0.1").unwrap();
323 + let build_id = start_build(state.clone(), app, version, vec!["linux/x86_64".parse().unwrap()])
324 + .await
325 + .unwrap();
326 +
327 + // Wait for the target run to settle.
328 + let mut status = String::new();
329 + for _ in 0..100 {
330 + status = sqlx::query_scalar("SELECT status FROM target_runs WHERE build_id = ?")
331 + .bind(build_id)
332 + .fetch_one(&pool)
333 + .await
334 + .unwrap();
335 + if status != "running" {
336 + break;
337 + }
338 + tokio::time::sleep(std::time::Duration::from_millis(50)).await;
339 + }
340 + assert_eq!(status, "ok", "target run should succeed");
341 +
342 + // Both steps recorded and finished ok.
343 + let steps: Vec<(String, String)> =
344 + sqlx::query_as("SELECT step, status FROM step_runs WHERE target_run_id IN (SELECT id FROM target_runs WHERE build_id = ?) ORDER BY id")
345 + .bind(build_id)
346 + .fetch_all(&pool)
347 + .await
348 + .unwrap();
349 + let names: Vec<&str> = steps.iter().map(|(s, _)| s.as_str()).collect();
350 + assert_eq!(names, vec!["build", "collect"]);
351 + assert!(steps.iter().all(|(_, st)| st == "ok"));
352 +
353 + // Artifact landed in dist_root and a step log was written.
354 + let artifact = state.cfg.dist_root.join("demo/0.0.1/demo.bin");
355 + assert!(artifact.exists(), "collect should copy the artifact");
356 + let log = state.cfg.logs_root.join("demo/0.0.1/linux-x86_64/build.log");
357 + assert!(log.exists(), "build step log should exist");
358 + assert!(std::fs::read_to_string(&log).unwrap().contains("compiling"));
359 + }
360 + }
@@ -0,0 +1,25 @@
1 + use crate::config::Config;
2 + use crate::domain::{AppId, Target};
3 + use crate::events::EventTx;
4 + use crate::ota::OtaRegistry;
5 + use crate::topology::Topology;
6 + use metrics_exporter_prometheus::PrometheusHandle;
7 + use sqlx::SqlitePool;
8 + use std::collections::HashMap;
9 + use std::sync::Arc;
10 + use tokio::sync::Mutex;
11 + use tokio::task::AbortHandle;
12 +
13 + #[derive(Clone)]
14 + pub struct AppState {
15 + pub pool: SqlitePool,
16 + pub topo: Arc<Topology>,
17 + pub cfg: Arc<Config>,
18 + pub prom: PrometheusHandle,
19 + pub events: EventTx,
20 + pub ota: Arc<OtaRegistry>,
21 + /// Single-slot guard per `(app, target)`: a newer build for the same
22 + /// target aborts the in-flight one (latest request wins), mirroring
23 + /// Sando's `active_build`. Other targets keep running — that's the fan-out.
24 + pub active: Arc<Mutex<HashMap<(AppId, Target), AbortHandle>>>,
25 + }
@@ -0,0 +1,150 @@
1 + //! Build matrix + host topology (`bento.toml`).
2 + //!
3 + //! Three orthogonal axes, all declared here: hosts (what can build natively),
4 + //! apps (what ships which targets and where its recipes live), and the
5 + //! implicit target axis that ties them together. Adding a platform is config —
6 + //! a new recipe plus a host that declares the target — not code.
7 +
8 + use crate::domain::{AppId, Target};
9 + use anyhow::{Context, Result};
10 + use ops_core::remote::RemoteHost;
11 + use serde::Deserialize;
12 + use std::collections::HashMap;
13 + use std::path::Path;
14 +
15 + #[derive(Debug, Clone, Deserialize)]
16 + pub struct Topology {
17 + #[serde(default, rename = "host")]
18 + pub hosts: Vec<Host>,
19 + /// `[app.<name>]` table.
20 + #[serde(default)]
21 + pub app: HashMap<String, AppConfig>,
22 + }
23 +
24 + #[derive(Debug, Clone, Deserialize)]
25 + pub struct Host {
26 + pub name: String,
27 + /// Tailnet alias or `user@host`; `local` runs commands directly.
28 + pub ssh: String,
29 + /// Targets this host can build natively. A target only dispatches to a host
30 + /// that lists it — this is how no-cross-compile is enforced structurally.
31 + #[serde(default)]
32 + pub targets: Vec<Target>,
33 + }
34 +
35 + #[derive(Debug, Clone, Deserialize)]
36 + pub struct AppConfig {
37 + /// Checkout path on each build host (apps are cloned on every host).
38 + pub repo: String,
39 + #[serde(default = "default_branch")]
40 + pub branch: String,
41 + /// Recipe directory relative to the repo (`dist/recipes`).
42 + #[serde(default = "default_recipe_dir")]
43 + pub recipe_dir: String,
44 + /// Targets this app ships.
45 + pub targets: Vec<Target>,
46 + }
47 +
48 + fn default_branch() -> String {
49 + "main".into()
50 + }
51 + fn default_recipe_dir() -> String {
52 + "dist/recipes".into()
53 + }
54 +
55 + impl Topology {
56 + pub fn load(path: &Path) -> Result<Self> {
57 + let raw = std::fs::read_to_string(path)
58 + .with_context(|| format!("reading topology at {}", path.display()))?;
59 + let topo: Topology = toml::from_str(&raw)?;
60 + topo.validate()?;
61 + Ok(topo)
62 + }
63 +
64 + fn validate(&self) -> Result<()> {
65 + anyhow::ensure!(!self.hosts.is_empty(), "topology must declare at least one host");
66 + anyhow::ensure!(!self.app.is_empty(), "topology must declare at least one app");
67 + // Every target an app ships must have a host that can build it.
68 + for (name, app) in &self.app {
69 + for t in &app.targets {
70 + if self.host_for(*t).is_none() {
71 + anyhow::bail!("app `{name}` ships target {t} but no host declares it");
72 + }
73 + }
74 + }
75 + Ok(())
76 + }
77 +
78 + /// The first host that declares `target` as buildable.
79 + pub fn host_for(&self, target: Target) -> Option<&Host> {
80 + self.hosts.iter().find(|h| h.targets.contains(&target))
81 + }
82 +
83 + /// Map of host name -> `RemoteHost`, for the recipe `sh(host, cmd)` API.
84 + pub fn remote_hosts(&self) -> HashMap<String, RemoteHost> {
85 + self.hosts.iter().map(|h| (h.name.clone(), RemoteHost::new(h.ssh.clone()))).collect()
86 + }
87 +
88 + pub fn app(&self, app: &AppId) -> Option<&AppConfig> {
89 + self.app.get(app.as_str())
90 + }
91 + }
92 +
93 + #[cfg(test)]
94 + mod tests {
95 + use super::*;
96 +
97 + const SAMPLE: &str = r#"
98 + [[host]]
99 + name = "fw13"
100 + ssh = "local"
101 + targets = ["linux/x86_64"]
102 +
103 + [[host]]
104 + name = "mbp"
105 + ssh = "mbp"
106 + targets = ["macos/aarch64", "ios/universal"]
107 +
108 + [app.goingson]
109 + repo = "~/Code/Apps/goingson"
110 + targets = ["macos/aarch64", "linux/x86_64"]
111 + "#;
112 +
113 + fn load(s: &str) -> Result<Topology> {
114 + let t: Topology = toml::from_str(s)?;
115 + t.validate()?;
116 + Ok(t)
117 + }
118 +
119 + #[test]
120 + fn parses_and_resolves_hosts() {
121 + let t = load(SAMPLE).unwrap();
122 + assert_eq!(t.hosts.len(), 2);
123 + let target: Target = "macos/aarch64".parse().unwrap();
124 + assert_eq!(t.host_for(target).unwrap().name, "mbp");
125 + assert_eq!(t.app(&"goingson".into()).unwrap().branch, "main");
126 + }
127 +
128 + #[test]
129 + fn rejects_target_without_a_host() {
130 + let bad = r#"
131 + [[host]]
132 + name = "fw13"
133 + ssh = "local"
134 + targets = ["linux/x86_64"]
135 +
136 + [app.goingson]
137 + repo = "x"
138 + targets = ["windows/x86_64"]
139 + "#;
140 + assert!(load(bad).is_err());
141 + }
142 +
143 + #[test]
144 + fn remote_hosts_marks_local() {
145 + let t = load(SAMPLE).unwrap();
146 + let hosts = t.remote_hosts();
147 + assert!(hosts["fw13"].is_local());
148 + assert!(!hosts["mbp"].is_local());
149 + }
150 + }
@@ -0,0 +1,22 @@
1 + [package]
2 + name = "bento-tui"
3 + version = "0.1.0"
4 + edition = "2024"
5 + license = "MIT"
6 +
7 + [[bin]]
8 + name = "bento"
9 + path = "src/main.rs"
10 +
11 + [dependencies]
12 + bento-daemon = { path = "../daemon" }
13 + ratatui = "0.29"
14 + crossterm = "0.28"
15 + tokio = { version = "1.50.0", features = ["macros", "rt-multi-thread", "net", "signal", "sync", "time"] }
16 + tokio-tungstenite = { version = "0.24", default-features = false, features = ["connect", "rustls-tls-webpki-roots"] }
17 + futures-util = { version = "0.3", default-features = false }
18 + reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
19 + serde = { version = "1.0.228", features = ["derive"] }
20 + serde_json = "1"
21 + anyhow = "1.0.102"
22 + chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] }
@@ -0,0 +1,701 @@
1 + //! bento-tui — operator front-end for bentod.
2 + //!
3 + //! Layout (top to bottom):
4 + //!
5 + //! ┌ daemon ─────────────────────────────────────────────────────┐
6 + //! │ bento -> http://...:7800 (ws ok) build: goingson 0.4.1 │
7 + //! ├ matrix ── target x step (↑/↓ select target) ────────────────│
8 + //! │ target chk pre bld sgn ntz stp vfy pkg pub col │
9 + //! │>macos/aarch64 O O > . . . . . . . │
10 + //! │ linux/x86_64 O O O - - O - - O O │
11 + //! ├ events (WS /events) ────────────────────────────────────────│
12 + //! │ 09:21:03 target_start linux/x86_64 │
13 + //! ├ tail [42] linux/x86_64 build — in-flight ───────────────────│
14 + //! │ Compiling goingson v0.4.1 │
15 + //! ├ status / keys ──────────────────────────────────────────────│
16 + //! │ [b] build [R] retry target [↑↓] select [[/]] tail [q] quit│
17 + //! └─────────────────────────────────────────────────────────────┘
18 +
19 + use anyhow::{Context, Result};
20 + use crossterm::event::{self, Event as XEvent, KeyCode, KeyModifiers};
21 + use crossterm::terminal::{
22 + EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
23 + };
24 + use futures_util::StreamExt;
25 + use ratatui::prelude::*;
26 + use ratatui::widgets::{Block, Borders, Cell, List, ListItem, Paragraph, Row, Table};
27 + use bento_daemon::domain::{Step, StepRunId};
28 + use bento_daemon::events::{Event, EventEnvelope};
29 + use serde::Deserialize;
30 + use std::collections::{BTreeMap, VecDeque};
31 + use std::io;
32 + use std::sync::{Arc, Mutex};
33 + use std::time::Duration;
34 + use tokio::sync::mpsc;
35 +
36 + // ---------- daemon types (subset of GET /state) ----------
37 +
38 + #[derive(Clone, Debug, Deserialize)]
39 + struct StateView {
40 + build: Option<BuildView>,
41 + }
42 +
43 + #[derive(Clone, Debug, Deserialize)]
44 + struct BuildView {
45 + #[allow(dead_code)]
46 + id: i64,
47 + app: String,
48 + version: String,
49 + status: String,
50 + targets: Vec<TargetView>,
51 + }
52 +
53 + #[derive(Clone, Debug, Deserialize)]
54 + struct TargetView {
55 + target: String,
56 + status: String,
57 + #[allow(dead_code)]
58 + current_step: Option<String>,
59 + error: Option<String>,
60 + steps: Vec<StepView>,
61 + }
62 +
63 + #[derive(Clone, Debug, Deserialize)]
64 + struct StepView {
65 + #[allow(dead_code)]
66 + run_id: i64,
67 + step: String,
68 + status: String,
69 + }
70 +
71 + /// Short column headers for the matrix, in canonical step order.
72 + const STEP_COLS: [(Step, &str); 10] = [
73 + (Step::Checkout, "chk"),
74 + (Step::Prebuild, "pre"),
75 + (Step::Build, "bld"),
76 + (Step::Sign, "sgn"),
77 + (Step::Notarize, "ntz"),
78 + (Step::Staple, "stp"),
79 + (Step::Verify, "vfy"),
80 + (Step::Package, "pkg"),
81 + (Step::Publish, "pub"),
82 + (Step::Collect, "col"),
83 + ];
84 +
85 + // ---------- shared app state ----------
86 +
87 + #[derive(Default)]
88 + struct Shared {
89 + state: Option<StateView>,
90 + last_err: Option<String>,
91 + events: VecDeque<String>,
92 + ws_ok: bool,
93 + selected: usize,
94 + notice: Option<String>,
95 + tails: BTreeMap<StepRunId, StepTail>,
96 + focus_run: Option<StepRunId>,
97 + }
98 +
99 + const EVENTS_CAP: usize = 200;
100 + const TAILS_CAP: usize = 10;
101 + const TAIL_LINES_CAP: usize = 200;
102 +
103 + /// Per-step live-tail buffer. Receives `StepLogChunk` text and presents it as a
104 + /// line-aware ring; chunks are not line-aligned at the transport, so a trailing
105 + /// partial line is buffered across chunks.
106 + struct StepTail {
107 + target: String,
108 + step: String,
109 + lines: VecDeque<String>,
110 + partial: String,
111 + status: TailStatus,
112 + }
113 +
114 + #[derive(Clone, PartialEq, Eq)]
115 + enum TailStatus {
116 + InFlight,
117 + Finished(String),
118 + }
119 +
120 + impl StepTail {
121 + fn new(target: String, step: String) -> Self {
122 + Self {
123 + target,
124 + step,
125 + lines: VecDeque::new(),
126 + partial: String::new(),
127 + status: TailStatus::InFlight,
128 + }
129 + }
130 +
131 + fn push_chunk(&mut self, text: &str) {
132 + let combined = std::mem::take(&mut self.partial) + text;
133 + let mut rest = combined.as_str();
134 + while let Some(idx) = rest.find('\n') {
135 + let (line, after) = rest.split_at(idx);
136 + self.push_line(line.trim_end_matches('\r').to_string());
137 + rest = &after[1..];
138 + }
139 + self.partial = rest.to_string();
140 + }
141 +
142 + fn push_line(&mut self, line: String) {
143 + if self.lines.len() >= TAIL_LINES_CAP {
144 + self.lines.pop_front();
145 + }
146 + self.lines.push_back(line);
147 + }
148 +
149 + fn finalize(&mut self, status_word: String) {
150 + let trailing = std::mem::take(&mut self.partial);
151 + if !trailing.is_empty() {
152 + self.push_line(trailing);
153 + }
154 + self.status = TailStatus::Finished(status_word);
155 + }
156 + }
157 +
158 + impl Shared {
159 + fn push_event(&mut self, line: String) {
160 + if self.events.len() >= EVENTS_CAP {
161 + self.events.pop_front();
162 + }
163 + self.events.push_back(line);
164 + }
165 +
166 + fn open_tail(&mut self, run_id: StepRunId, target: String, step: String) {
167 + if self.tails.len() >= TAILS_CAP {
168 + if let Some((&oldest, _)) = self.tails.iter().next() {
169 + self.tails.remove(&oldest);
170 + }
171 + }
172 + self.tails.insert(run_id, StepTail::new(target, step));
173 + self.focus_run = Some(run_id);
174 + }
175 +
176 + fn push_tail_chunk(&mut self, run_id: StepRunId, text: &str) {
177 + if let Some(t) = self.tails.get_mut(&run_id) {
178 + t.push_chunk(text);
179 + }
180 + }
181 +
182 + fn finalize_tail(&mut self, run_id: StepRunId, status_word: String) {
183 + if let Some(t) = self.tails.get_mut(&run_id) {
184 + t.finalize(status_word);
185 + }
186 + }
187 +
188 + /// App to act on: the latest build's app, else the env default.
189 + fn app_name(&self, default_app: &str) -> String {
190 + self.state
191 + .as_ref()
192 + .and_then(|s| s.build.as_ref())
193 + .map(|b| b.app.clone())
194 + .unwrap_or_else(|| default_app.to_string())
195 + }
196 +
197 + fn selected_target(&self) -> Option<String> {
198 + self.state
199 + .as_ref()
200 + .and_then(|s| s.build.as_ref())
201 + .and_then(|b| b.targets.get(self.selected))
202 + .map(|t| t.target.clone())
203 + }
204 + }
205 +
206 + // ---------- main ----------
207 +
208 + fn main() -> Result<()> {
209 + let daemon = std::env::var("BENTO_DAEMON").unwrap_or_else(|_| "http://127.0.0.1:7800".into());
210 + let default_app = std::env::var("BENTO_APP").unwrap_or_else(|_| "goingson".into());
211 + let shared = Arc::new(Mutex::new(Shared::default()));
212 +
213 + let rt = tokio::runtime::Builder::new_multi_thread()
214 + .enable_all()
215 + .worker_threads(2)
216 + .build()?;
217 +
218 + let _g = rt.enter();
219 + rt.spawn(state_poller(daemon.clone(), shared.clone()));
220 + rt.spawn(events_subscriber(daemon.clone(), shared.clone()));
221 +
222 + enable_raw_mode()?;
223 + let mut stdout = io::stdout();
224 + crossterm::execute!(stdout, EnterAlternateScreen)?;
225 + let backend = CrosstermBackend::new(stdout);
226 + let mut term = Terminal::new(backend)?;
227 +
228 + let res = ui_loop(&mut term, &daemon, &default_app, &shared, rt.handle());
229 +
230 + disable_raw_mode()?;
231 + crossterm::execute!(term.backend_mut(), LeaveAlternateScreen)?;
232 + term.show_cursor()?;
233 + res
234 + }
235 +
236 + // ---------- background tasks ----------
237 +
238 + async fn state_poller(daemon: String, shared: Arc<Mutex<Shared>>) {
239 + let url = format!("{daemon}/state");
240 + let client = reqwest::Client::new();
241 + loop {
242 + match client.get(&url).timeout(Duration::from_secs(2)).send().await {
243 + Ok(resp) if resp.status().is_success() => match resp.json::<StateView>().await {
244 + Ok(s) => {
245 + let mut g = shared.lock().unwrap();
246 + let n = s
247 + .build
248 + .as_ref()
249 + .map(|b| b.targets.len().saturating_sub(1))
250 + .unwrap_or(0);
251 + if g.selected > n {
252 + g.selected = n;
253 + }
254 + g.state = Some(s);
255 + g.last_err = None;
256 + }
257 + Err(e) => shared.lock().unwrap().last_err = Some(format!("decode: {e}")),
258 + },
259 + Ok(resp) => shared.lock().unwrap().last_err = Some(format!("status {}", resp.status())),
260 + Err(e) => shared.lock().unwrap().last_err = Some(e.to_string()),
261 + }
262 + tokio::time::sleep(Duration::from_secs(2)).await;
263 + }
264 + }
265 +
266 + async fn events_subscriber(daemon: String, shared: Arc<Mutex<Shared>>) {
267 + let ws_url = ws_url_from(&daemon);
268 + loop {
269 + match tokio_tungstenite::connect_async(&ws_url).await {
270 + Ok((mut socket, _resp)) => {
271 + shared.lock().unwrap().ws_ok = true;
272 + while let Some(msg) = socket.next().await {
273 + match msg {
274 + Ok(tokio_tungstenite::tungstenite::Message::Text(t)) => {
275 + dispatch_ws_frame(&shared, &t)
276 + }
277 + Ok(tokio_tungstenite::tungstenite::Message::Close(_)) | Err(_) => break,
278 + _ => {}
279 + }
280 + }
281 + shared.lock().unwrap().ws_ok = false;
282 + }
283 + Err(_) => shared.lock().unwrap().ws_ok = false,
284 + }
285 + tokio::time::sleep(Duration::from_secs(3)).await;
286 + }
287 + }
288 +
289 + fn ws_url_from(daemon: &str) -> String {
290 + daemon.replacen("https://", "wss://", 1).replacen("http://", "ws://", 1) + "/events"
291 + }
292 +
293 + /// Route a WS frame: log chunks update the per-step tail; everything else
294 + /// becomes one line in the events ring. (Chunks never hit the ring — a busy
295 + /// build emits hundreds per second and would evict every other event.)
296 + fn dispatch_ws_frame(shared: &Arc<Mutex<Shared>>, raw: &str) {
297 + if let Some(line) = format_lagged(raw) {
298 + shared.lock().unwrap().push_event(line);
299 + return;
300 + }
301 + let Ok(env) = serde_json::from_str::<EventEnvelope>(raw) else {
302 + shared.lock().unwrap().push_event(raw.to_string());
303 + return;
304 + };
305 + match &env.event {
306 + Event::StepStart { run_id, target, step, .. } => {
307 + let mut g = shared.lock().unwrap();
308 + g.open_tail(*run_id, target.to_string(), step.to_string());
309 + let line = format_event_line(&env);
310 + g.push_event(line);
311 + }
312 + Event::StepLogChunk { run_id, text, .. } => {
313 + shared.lock().unwrap().push_tail_chunk(*run_id, text);
314 + }
315 + Event::StepDone { run_id, status, .. } => {
316 + let word = status.as_str().to_string();
317 + let mut g = shared.lock().unwrap();
318 + g.finalize_tail(*run_id, word);
319 + let line = format_event_line(&env);
320 + g.push_event(line);
321 + }
322 + _ => {
323 + let line = format_event_line(&env);
324 + shared.lock().unwrap().push_event(line);
325 + }
326 + }
327 + }
328 +
329 + fn format_event_line(env: &EventEnvelope) -> String {
330 + let time = env.at.format("%H:%M:%S").to_string();
331 + format!("{time} {}", format_event_body(&env.event))
332 + }
333 +
334 + fn format_event_body(e: &Event) -> String {
335 + match e {
336 + Event::BuildRequested { app, version, targets } => {
337 + format!("build_requested {app} {version} [{}]", targets.iter().map(|t| t.to_string()).collect::<Vec<_>>().join(","))
338 + }
339 + Event::TargetAborted { target, .. } => format!("target_aborted {target}"),
340 + Event::TargetStart { target, .. } => format!("target_start {target}"),
341 + Event::StepStart { target, step, .. } => format!("step_start {target} {step}"),
342 + Event::StepLogChunk { run_id, text, .. } => format!("log[{run_id}] {}", truncate(text.trim_end(), 80)),
343 + Event::StepDone { target, step, status, .. } => format!("step_done {target} {step} {}", status.as_str()),
344 + Event::TargetOk { target, artifacts, .. } => format!("target_ok {target} ({} artifacts)", artifacts.len()),
345 + Event::TargetFailed { target, step, error, .. } => format!("target_failed {target} {step}: {}", truncate(error, 80)),
346 + Event::NotarizeRetry { target, attempt, reason, .. } => format!("notarize_retry {target} #{attempt} {reason}"),
347 + Event::ArtifactCollected { target, path, bytes, .. } => format!("artifact {target} {path} {bytes}B"),
348 + Event::PublishOk { target, channel, .. } => format!("publish_ok {target} {channel}"),
349 + Event::PublishFailed { target, channel, error, .. } => format!("publish_failed {target} {channel}: {}", truncate(error, 80)),
350 + }
351 + }
352 +
353 + fn format_lagged(raw: &str) -> Option<String> {
354 + let v: serde_json::Value = serde_json::from_str(raw).ok()?;
355 + if v.get("kind")?.as_str()? == "lagged" {
356 + Some(format!("(lagged, skipped {})", v.get("skipped").and_then(|n| n.as_i64()).unwrap_or(0)))
357 + } else {
358 + None
359 + }
360 + }
361 +
362 + // ---------- actions ----------
363 +
364 + #[derive(Clone, Debug)]
365 + enum Action {
366 + Build { app: String },
367 + Retry { app: String, target: String },
368 + }
369 +
370 + impl std::fmt::Display for Action {
371 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
372 + match self {
373 + Action::Build { app } => write!(f, "build {app}"),
374 + Action::Retry { app, target } => write!(f, "retry {app}/{target}"),
375 + }
376 + }
377 + }
378 +
379 + async fn dispatch_action(daemon: &str, act: &Action) -> Result<String> {
380 + let client = reqwest::Client::new();
381 + let (url, body) = match act {
382 + Action::Build { app } => (format!("{daemon}/build"), serde_json::json!({ "app": app })),
383 + Action::Retry { app, target } => {
384 + (format!("{daemon}/retry"), serde_json::json!({ "app": app, "target": target }))
385 + }
386 + };
387 + let resp = client
388 + .post(&url)
389 + .timeout(Duration::from_secs(30))
390 + .json(&body)
391 + .send()
392 + .await
393 + .context("send")?;
394 + let status = resp.status();
395 + let text = resp.text().await.unwrap_or_default();
396 + if status.is_success() {
397 + Ok(truncate(&text, 120))
398 + } else {
399 + Err(anyhow::anyhow!("HTTP {status}: {}", truncate(&text, 200)))
400 + }
401 + }
402 +
403 + // ---------- ui loop ----------
404 +
405 + fn ui_loop<B: Backend>(
406 + term: &mut Terminal<B>,
407 + daemon: &str,
408 + default_app: &str,
409 + shared: &Arc<Mutex<Shared>>,
410 + rt: &tokio::runtime::Handle,
411 + ) -> Result<()> {
412 + let (action_tx, mut action_rx) = mpsc::channel::<Action>(32);
413 + {
414 + let shared = shared.clone();
415 + let daemon = daemon.to_string();
416 + rt.spawn(async move {
417 + while let Some(act) = action_rx.recv().await {
418 + let res = dispatch_action(&daemon, &act).await;
419 + let line = match res {
420 + Ok(msg) => format!("[ok] {act}: {msg}"),
421 + Err(e) => format!("[err] {act}: {e}"),
422 + };
423 + let mut g = shared.lock().unwrap();
424 + g.notice = Some(line.clone());
425 + g.push_event(format!(" action {line}"));
426 + }
427 + });
428 + }
429 +
430 + loop {
431 + term.draw(|f| draw(f, daemon, shared))?;
432 +
433 + if event::poll(Duration::from_millis(120))? {
434 + if let XEvent::Key(k) = event::read()? {
435 + match k.code {
436 + KeyCode::Char('q') | KeyCode::Esc => return Ok(()),
437 + KeyCode::Char('c') if k.modifiers.contains(KeyModifiers::CONTROL) => {
438 + return Ok(());
439 + }
440 + KeyCode::Up | KeyCode::Char('k') => {
441 + let mut g = shared.lock().unwrap();
442 + g.selected = g.selected.saturating_sub(1);
443 + }
444 + KeyCode::Down | KeyCode::Char('j') => {
445 + let mut g = shared.lock().unwrap();
446 + let max = g
447 + .state
448 + .as_ref()
449 + .and_then(|s| s.build.as_ref())
450 + .map(|b| b.targets.len().saturating_sub(1))
451 + .unwrap_or(0);
452 + if g.selected < max {
453 + g.selected += 1;
454 + }
455 + }
456 + KeyCode::Char('b') => {
457 + let app = shared.lock().unwrap().app_name(default_app);
458 + let _ = action_tx.try_send(Action::Build { app });
459 + }
460 + KeyCode::Char('R') => {
461 + let (app, target) = {
462 + let g = shared.lock().unwrap();
463 + (g.app_name(default_app), g.selected_target())
464 + };
465 + if let Some(target) = target {
466 + let _ = action_tx.try_send(Action::Retry { app, target });
467 + }
468 + }
469 + KeyCode::Char('r') => {
470 + shared.lock().unwrap().notice = Some("refresh on next tick".into());
471 + }
472 + KeyCode::Char('[') => {
473 + let mut g = shared.lock().unwrap();
474 + g.focus_run = cycle_focus(&g.tails, g.focus_run, -1);
475 + }
476 + KeyCode::Char(']') => {
477 + let mut g = shared.lock().unwrap();
478 + g.focus_run = cycle_focus(&g.tails, g.focus_run, 1);
479 + }
480 + _ => {}
481 + }
482 + }
483 + }
484 + }
485 + }
486 +
487 + // ---------- render ----------
488 +
489 + fn draw(f: &mut Frame, daemon: &str, shared: &Arc<Mutex<Shared>>) {
490 + let g = shared.lock().unwrap();
491 + let chunks = Layout::default()
492 + .direction(Direction::Vertical)
493 + .constraints([
494 + Constraint::Length(3), // header
495 + Constraint::Length(10), // matrix
496 + Constraint::Min(4), // events
497 + Constraint::Length(8), // tail
498 + Constraint::Length(2), // status
499 + ])
500 + .split(f.area());
Lines truncated
@@ -0,0 +1,13 @@
1 + [package]
2 + name = "egui-updater"
3 + version = "0.0.0"
4 + edition = "2024"
5 + license = "MIT"
6 + description = "OTA self-update for egui/eframe desktop apps: poll a manifest, verify a minisign signature, download and apply. (Placeholder — implementation in progress.)"
7 + readme = "README.md"
8 + keywords = ["egui", "eframe", "updater", "self-update", "ota"]
9 + categories = ["gui", "development-tools"]
10 + # repository/homepage: set before the real release — see README. Omitted on the
11 + # 0.0.0 name-reservation placeholder.
12 +
13 + [dependencies]