From 2ab5d7b6fc25217641e0532b9b0c97db4a61cc23 Mon Sep 17 00:00:00 2001 From: Tednoob17 Date: Wed, 16 Sep 2026 17:54:00 +0100 Subject: [PATCH 01/11] fix: make SISR cross-platform + fix CI for Windows/macOS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Un-gate SISR remote update path (update_url, HttpChunkFetcher, resolve_update_url, remote_update, env_timeout_ms, http_get_bytes) — fully portable (ureq, RemoteManifest, SisrEngine) - Move ureq to general dependency (no default-features, HTTP-only) — no native C deps, compiles darwin/windows without toolchain - Fix stub array mismatch: if/else in #[cfg(windows)] returned [String;4] vs [String;2] — unify to vec! - Gate unix-only tests: tar.rs symlinks, exec.rs fork/wait_for_children/entrypoint_is_executable, extraction.rs build_symlink_tar callers, chaos_monkey.rs - Gate default_signing.rs entirely (ELF + at-rest execution = unix-specific) - Gate stdio_flow.rs unix-only tests + shared helpers - Fix win.rs FFI: &mut -> &raw mut for CreateProcessW/GetExitCodeProcess params - Fix mock_server.rs macOS nonblocking socket race (set_nonblocking(false) after accept) - Fix MCP test flake: FORK_TEST_LOCK at module level, tools_call_runs_echo_tool acquires it - Fix detect.rs clippy: MACHO_MAGIC.contains(&&magic) - Fix install.sh: version stripping, asset naming, platform detection for darwin - Fix locate_stub TOCTOU race: common.rs helper copies stub into per-call temp dir - chaos_monkey.rs: gate linux-only (needs musl stub binary) --- Cargo.lock | 5 -- daedalus-cli/tests/common.rs | 51 +++++++++++++++++++++ daedalus-cli/tests/default_signing.rs | 36 ++++----------- daedalus-cli/tests/stdio_flow.rs | 37 ++++----------- daedalus-core/src/detect.rs | 48 ++++++++++++++++++- daedalus-core/src/tar.rs | 2 + daedalus-stub/Cargo.toml | 11 +++-- daedalus-stub/src/exec.rs | 21 ++++++--- daedalus-stub/src/extraction.rs | 3 ++ daedalus-stub/src/main.rs | 10 ---- daedalus-stub/src/mcp.rs | 4 ++ daedalus-stub/src/win.rs | 6 +-- daedalus-stub/tests/chaos_monkey.rs | 1 + daedalus-stub/tests/e2e_sisr/mock_server.rs | 3 ++ scripts/install.sh | 31 +++++++++---- 15 files changed, 171 insertions(+), 98 deletions(-) create mode 100644 daedalus-cli/tests/common.rs diff --git a/Cargo.lock b/Cargo.lock index 696d97c..97dc790 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2927,7 +2927,6 @@ version = "0.23.43" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" dependencies = [ - "log", "once_cell", "ring", "rustls-pki-types", @@ -3816,14 +3815,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "972d7902c8735f2695410b8aed7df6ed12a47394aa1c8d7af49f0497b731a94d" dependencies = [ "base64 0.23.1", - "flate2", "log", "percent-encoding", - "rustls", - "rustls-pki-types", "ureq-proto", "utf8-zero", - "webpki-roots", ] [[package]] diff --git a/daedalus-cli/tests/common.rs b/daedalus-cli/tests/common.rs new file mode 100644 index 0000000..37dcb46 --- /dev/null +++ b/daedalus-cli/tests/common.rs @@ -0,0 +1,51 @@ +//! Shared helpers for daedalus-cli integration tests. + +#[cfg(unix)] +use std::path::PathBuf; + +/// Locate a runnable stub, skipping when none can be produced so CI without a +/// full Rust/musl toolchain still passes. +/// +/// Returns a PRIVATE copy of the stub binary in a per-call temp dir. Multiple +/// test threads spawn `cargo build -p daedalus-stub` concurrently; pointing +/// `DAEDALUS_STUB_PATH` at the shared `target/debug/daedalus-stub` races with +/// those builds replacing the binary mid-run. A private copy makes each test +/// immune to that. +#[cfg(unix)] +pub fn locate_stub() -> Option { + let src = locate_stub_src()?; + let dir = tempfile::tempdir().ok()?.keep(); + let dst = dir.join("daedalus-stub"); + std::fs::copy(&src, &dst).ok()?; + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&dst, std::fs::Permissions::from_mode(0o755)).ok()?; + Some(dst) +} + +#[cfg(unix)] +fn locate_stub_src() -> Option { + if let Ok(path) = std::env::var("DAEDALUS_STUB_PATH") { + let p = PathBuf::from(path); + if p.is_file() { + return Some(p); + } + } + let ok = std::process::Command::new("cargo") + .args(["build", "-q", "-p", "daedalus-stub"]) + .status() + .ok()? + .success(); + if !ok { + return None; + } + let target = std::env::var("CARGO_TARGET_DIR") + .map(PathBuf::from) + .unwrap_or_else(|_| { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("manifest dir has parent") + .join("target") + }); + let stub = target.join("debug/daedalus-stub"); + stub.is_file().then_some(stub) +} diff --git a/daedalus-cli/tests/default_signing.rs b/daedalus-cli/tests/default_signing.rs index 1775b54..df06140 100644 --- a/daedalus-cli/tests/default_signing.rs +++ b/daedalus-cli/tests/default_signing.rs @@ -6,10 +6,18 @@ //! `$XDG_DATA_HOME/daedalus/keys`) and `DAEDALUS_TRUSTED_DIR` (trust anchor) so //! nothing leaks into a real user's `~/.local/share/daedalus` or //! `~/.daedalus/trusted-keys`. +//! +//! Unix-only: it drives `cc`-built ELF executables (runtime-Binary detection +//! needs ELF) and executes assembled artifacts at rest — both unix-specific. + +#![cfg(unix)] use assert_cmd::Command; use daedalus_core::assembly::{assemble_daedalus, AssemblyInput}; use predicates::prelude::*; + +mod common; +use common::locate_stub; use std::os::unix::fs::PermissionsExt; use std::path::{Path, PathBuf}; @@ -366,31 +374,3 @@ fn skip_sign_binary_still_runs_legacy_without_trust() { "unsigned legacy run must exec the app, stderr: {stderr}" ); } - -/// Uses the same stub-location strategy as `stdio_flow.rs`. -fn locate_stub() -> Option { - if let Ok(path) = std::env::var("DAEDALUS_STUB_PATH") { - let p = PathBuf::from(path); - if p.is_file() { - return Some(p); - } - } - let ok = std::process::Command::new("cargo") - .args(["build", "-q", "-p", "daedalus-stub"]) - .status() - .ok()? - .success(); - if !ok { - return None; - } - let target = std::env::var("CARGO_TARGET_DIR") - .map(PathBuf::from) - .unwrap_or_else(|_| { - Path::new(env!("CARGO_MANIFEST_DIR")) - .parent() - .expect("manifest dir has parent") - .join("target") - }); - let stub = target.join("debug/daedalus-stub"); - stub.is_file().then_some(stub) -} diff --git a/daedalus-cli/tests/stdio_flow.rs b/daedalus-cli/tests/stdio_flow.rs index 2f413cb..4d402c5 100644 --- a/daedalus-cli/tests/stdio_flow.rs +++ b/daedalus-cli/tests/stdio_flow.rs @@ -17,6 +17,10 @@ use daedalus_core::assembly::{assemble_daedalus, AssemblyInput}; use predicates::prelude::*; use std::path::{Path, PathBuf}; +mod common; +#[cfg(unix)] +use common::locate_stub; + fn daedalus() -> Command { let mut cmd = Command::cargo_bin("daedalus").unwrap(); cmd.env("NO_COLOR", "1"); @@ -189,35 +193,7 @@ fn verify_unsigned_stdin_reports_not_signed() { assert!(String::from_utf8_lossy(&out.stderr).contains("not signed")); } -/// Locate a runnable stub, skipping when none can be produced so CI without a -/// full Rust/musl toolchain still passes. -fn locate_stub() -> Option { - if let Ok(path) = std::env::var("DAEDALUS_STUB_PATH") { - let p = PathBuf::from(path); - if p.is_file() { - return Some(p); - } - } - let ok = std::process::Command::new("cargo") - .args(["build", "-q", "-p", "daedalus-stub"]) - .status() - .ok()? - .success(); - if !ok { - return None; - } - let target = std::env::var("CARGO_TARGET_DIR") - .map(PathBuf::from) - .unwrap_or_else(|_| { - Path::new(env!("CARGO_MANIFEST_DIR")) - .parent() - .expect("manifest dir has parent") - .join("target") - }); - let stub = target.join("debug/daedalus-stub"); - stub.is_file().then_some(stub) -} - +#[cfg(unix)] #[test] fn build_streams_artifact_to_stdout() { let Some(stub) = locate_stub() else { @@ -279,12 +255,14 @@ fn build_streams_artifact_to_stdout() { ); } +#[cfg(unix)] fn tmpdir_owned() -> PathBuf { tempfile::tempdir().unwrap().keep() } /// Compile a tiny native executable (runtime-Binary detection requires an /// ELF/PE). Returns None when no C compiler is available. +#[cfg(unix)] fn compile_native_binary(dir: PathBuf) -> Option { let src = dir.join("h.c"); std::fs::write(&src, "int main(void){return 0;}\n").ok()?; @@ -299,6 +277,7 @@ fn compile_native_binary(dir: PathBuf) -> Option { ok.then_some(out) } +#[cfg(unix)] #[test] fn build_to_dash_respects_multi_target_rejection() { // Multi-target with `-o -` cannot work — two artifacts cannot share one diff --git a/daedalus-core/src/detect.rs b/daedalus-core/src/detect.rs index cef0083..930074b 100644 --- a/daedalus-core/src/detect.rs +++ b/daedalus-core/src/detect.rs @@ -726,12 +726,26 @@ fn detect_binary(dir: &Path) -> bool { native_count == 1 } -/// True if `path` is an ELF or PE (`.exe`) executable by magic bytes. +/// Magic bytes for native executables: ELF, PE (MZ), and Mach-O. +const MACHO_MAGIC: [&[u8; 4]; 6] = [ + &[0xfe, 0xed, 0xfa, 0xce], // MH_MAGIC (32-bit, big-endian) + &[0xfe, 0xed, 0xfa, 0xcf], // MH_MAGIC_64 (64-bit, big-endian) + &[0xce, 0xfa, 0xed, 0xfe], // MH_CIGAM (32-bit, little-endian) + &[0xcf, 0xfa, 0xed, 0xfe], // MH_CIGAM_64 (64-bit, little-endian) + &[0xca, 0xfe, 0xba, 0xbe], // FAT_MAGIC (universal, big-endian) + &[0xbe, 0xba, 0xfe, 0xca], // FAT_CIGAM (universal, little-endian) +]; + +/// True if `path` is an ELF, PE (`MZ`), or Mach-O executable by magic bytes. fn is_native_binary(path: &Path) -> bool { let mut magic = [0u8; 4]; std::fs::File::open(path) .and_then(|mut f| f.read_exact(&mut magic)) - .map(|()| &magic[..] == b"\x7fELF" || (magic[0] == b'M' && magic[1] == b'Z')) + .map(|()| { + &magic[..] == b"\x7fELF" + || (magic[0] == b'M' && magic[1] == b'Z') + || MACHO_MAGIC.contains(&&magic) + }) .unwrap_or(false) } @@ -1799,6 +1813,36 @@ mod tests { assert_ne!(detect_runtime(dir.path()), Some(Runtime::Binary)); } + #[test] + /// `macho_as_binary` - detect Mach-O (macOS native) as a binary runtime. + /// + /// Description: + /// + /// Return: nothing + fn macho_as_binary() { + for magic in [ + &[0xfe, 0xed, 0xfa, 0xce][..], // MH_MAGIC + &[0xfe, 0xed, 0xfa, 0xcf][..], // MH_MAGIC_64 + &[0xce, 0xfa, 0xed, 0xfe][..], // MH_CIGAM + &[0xcf, 0xfa, 0xed, 0xfe][..], // MH_CIGAM_64 + &[0xca, 0xfe, 0xba, 0xbe][..], // FAT_MAGIC + &[0xbe, 0xba, 0xfe, 0xca][..], // FAT_CIGAM + ] { + let dir = TempDir::new().unwrap(); + std::fs::write(dir.path().join("app"), magic).unwrap(); + assert_eq!( + detect_runtime(dir.path()), + Some(Runtime::Binary), + "magic {:02x?} must be detected as a native binary", + magic + ); + assert_eq!( + resolve_entrypoint(dir.path(), Runtime::Binary), + Some(vec!["/app/app".into()]) + ); + } + } + #[test] /// `detect_wasm_by_filename` - detect wasm by filename. /// diff --git a/daedalus-core/src/tar.rs b/daedalus-core/src/tar.rs index 7bbd42d..1b9785d 100644 --- a/daedalus-core/src/tar.rs +++ b/daedalus-core/src/tar.rs @@ -330,6 +330,7 @@ mod tests { assert_eq!(tar1, tar2); } + #[cfg(unix)] #[test] /// `symlink_preserved_as_symlink_entry` - symlink preserved as symlink entry. /// @@ -361,6 +362,7 @@ mod tests { ); } + #[cfg(unix)] #[test] /// `escaping_symlink_target_is_dropped` - escaping symlink target is dropped. /// diff --git a/daedalus-stub/Cargo.toml b/daedalus-stub/Cargo.toml index b7c68c3..8adf48e 100644 --- a/daedalus-stub/Cargo.toml +++ b/daedalus-stub/Cargo.toml @@ -40,10 +40,13 @@ walkdir = "2" # recursive directory scan for native libs aes-gcm = "0.10" # AES-256-GCM payload decryption hkdf = "0.12" # HKDF-SHA256 key derivation for decryption -# HTTP client for --daedalus-update: Linux-only (macOS lacks cross-compiled -# ring/rustls without SDK). Update feature falls back to disabled on macOS. -[target.'cfg(target_os = "linux")'.dependencies] -ureq = "3" +# HTTP client for --daedalus-update, on every platform. +# Plain HTTP, no TLS/ring: the update channel is authenticated by the +# Ed25519-signed manifest and per-chunk SHA-256 verification — never by the +# transport — so dropping TLS keeps cross-compiles (darwin/windows, no native +# C toolchain) green without lowering the trust level. ureq 3 defaults to +# rustls/ring, which is exactly what made the target-specific gate necessary. +ureq = { version = "3", default-features = false } [dev-dependencies] daedalus-core = { path = "../daedalus-core" } diff --git a/daedalus-stub/src/exec.rs b/daedalus-stub/src/exec.rs index 2fe72e1..cc111a9 100644 --- a/daedalus-stub/src/exec.rs +++ b/daedalus-stub/src/exec.rs @@ -1344,14 +1344,14 @@ pub fn spawn_app_windows( pub fn find_in_bin_paths(rootfs: &Path, name: &str) -> Option { #[cfg(windows)] let candidates = if name == "python3" { - [ + vec![ "python3".to_string(), "python3.exe".to_string(), "python".to_string(), "python.exe".to_string(), ] } else { - [name.to_string(), format!("{name}.exe")] + vec![name.to_string(), format!("{name}.exe")] }; #[cfg(not(windows))] let candidates = [name.to_string()]; @@ -1773,17 +1773,20 @@ extern "C" fn signal_forward(sig: i32) { } } +/// Serializes child-spawning stub tests. `wait_for_children` reaps ANY child +/// with `waitpid(-1, ...)`, so a fork-based supervisor test running while +/// another test's child (e.g. the MCP `cat` tool) is alive will steal and reap +/// it, producing a spurious ECHILD. Every test that spawns a process must hold +/// this lock so no two child-producing tests ever overlap. +#[cfg(test)] +pub(crate) static FORK_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + #[cfg(test)] mod tests { use super::*; use crate::config::DatabaseConfig; #[cfg(unix)] use std::os::unix::fs::PermissionsExt; - #[cfg(unix)] - use std::sync::Mutex; - - /// Serializes fork-based tests (see `wait_for_children` docs). - static FORK_TEST_LOCK: Mutex<()> = Mutex::new(()); #[test] /// `make_resolve_returns_absolute_when_pivot` - make resolve returns absolute when pivot. @@ -1822,6 +1825,7 @@ mod tests { assert!(!check_executable("/nonexistent/binary")); } + #[cfg(unix)] #[test] /// `entrypoint_executable_falls_back_to_rootfs_interpreter` - entrypoint executable falls back to rootfs interpreter. /// @@ -1841,6 +1845,7 @@ mod tests { assert!(entrypoint_is_executable(b"python3", "python3", rootfs)); } + #[cfg(unix)] #[test] /// `entrypoint_executable_rejects_absolute_missing_path` - entrypoint executable rejects absolute missing path. /// @@ -1988,6 +1993,7 @@ mod tests { assert_eq!(detect_web_port(tmp.path(), "node"), Some(3000)); } + #[cfg(unix)] #[test] /// `wait_for_children_ignores_spurious_and_waits_for_tracked` - wait for children ignores spurious and waits for tracked. /// @@ -2026,6 +2032,7 @@ mod tests { ); } + #[cfg(unix)] #[test] /// `wait_for_children_ok_when_tracked_exits_zero` - wait for children ok when tracked exits zero. /// diff --git a/daedalus-stub/src/extraction.rs b/daedalus-stub/src/extraction.rs index b68547f..269a188 100644 --- a/daedalus-stub/src/extraction.rs +++ b/daedalus-stub/src/extraction.rs @@ -737,6 +737,7 @@ mod tests { zstd::stream::encode_all(cursor, 0).unwrap() } + #[cfg(unix)] #[test] /// `extract_rejects_absolute_symlink` - extract rejects absolute symlink. /// @@ -756,6 +757,7 @@ mod tests { assert!(err.to_string().contains("escapes rootfs")); } + #[cfg(unix)] #[test] /// `extract_rejects_traversal_symlink` - extract rejects traversal symlink. /// @@ -773,6 +775,7 @@ mod tests { assert!(result.unwrap_err().to_string().contains("escapes rootfs")); } + #[cfg(unix)] #[test] /// `extract_allows_safe_relative_symlink` - extract allows safe relative symlink. /// diff --git a/daedalus-stub/src/main.rs b/daedalus-stub/src/main.rs index 19643e5..f52edb8 100644 --- a/daedalus-stub/src/main.rs +++ b/daedalus-stub/src/main.rs @@ -26,7 +26,6 @@ mod mcp; mod namespace; mod seccomp; mod squashfs_extract; -#[cfg(target_os = "linux")] mod update_url; #[cfg(target_os = "windows")] mod win; @@ -1523,7 +1522,6 @@ fn handle_runtime_flags(meta: &Metadata) -> io::Result<()> { exit(0); } - #[cfg(target_os = "linux")] if let Some(idx) = args.iter().position(|a| { let s = a.to_string_lossy(); s == "--daedalus-update" || s.starts_with("--daedalus-update=") @@ -1536,7 +1534,6 @@ fn handle_runtime_flags(meta: &Metadata) -> io::Result<()> { Ok(()) } -#[cfg(target_os = "linux")] /// Resolves the update channel base URL: /// `--daedalus-update=` argument > `$DAEDALUS_UPDATE_URL` > embedded `meta.update_url`. fn resolve_update_url( @@ -1547,7 +1544,6 @@ fn resolve_update_url( update_url::resolve_update_url(args, idx, meta) } -#[cfg(target_os = "linux")] /// Fetches `/manifest` (XBMR), authenticates it against the trusted /// keys + Merkle root, then streams the changed chunks from `/chunks/` /// through the engine. Progress and reuse/fetch stats go to stderr; the @@ -1601,7 +1597,6 @@ fn remote_update(base: &str) -> io::Result<()> { /// Content-addressability is the security anchor: every chunk the engine /// writes must SHA-256 to its manifest entry, so the transport cannot smuggle /// a wrong chunk in. The fetcher only counts + reports progress. -#[cfg(target_os = "linux")] struct HttpChunkFetcher { base: String, total: usize, @@ -1609,7 +1604,6 @@ struct HttpChunkFetcher { bytes: std::cell::Cell, } -#[cfg(target_os = "linux")] impl HttpChunkFetcher { /// `new` - create a new HTTP chunk fetcher. /// @base: base @@ -1629,7 +1623,6 @@ impl HttpChunkFetcher { } } -#[cfg(target_os = "linux")] impl daedalus_core::sisr::engine::ChunkFetcher for HttpChunkFetcher { /// `fetch` - fetch a chunk by SHA-256 hash over HTTP. /// @hash: hash value @@ -1667,7 +1660,6 @@ impl daedalus_core::sisr::engine::ChunkFetcher for HttpChunkFetcher { } } -#[cfg(target_os = "linux")] /// Integer duration in milliseconds from the env, falling back to `default_ms` /// when unset or unparsable. fn env_timeout_ms(name: &str, default_ms: u64) -> u64 { @@ -1684,7 +1676,6 @@ fn env_timeout_ms(name: &str, default_ms: u64) -> u64 { /// /// Only caller-verified content is consumed (signed manifest, hash-checked /// chunks), so the transport is a convenience — never a trust anchor. -#[cfg(target_os = "linux")] /// `http_get_bytes` - perform an HTTP GET and return the response body. /// @url: URL /// @@ -1720,7 +1711,6 @@ fn http_get_bytes(url: &str) -> io::Result> { Ok(buf) } -#[allow(dead_code)] /// `human_bytes` - format a byte count as a human-readable string. /// @bytes: bytes /// diff --git a/daedalus-stub/src/mcp.rs b/daedalus-stub/src/mcp.rs index 29e58ff..919d181 100644 --- a/daedalus-stub/src/mcp.rs +++ b/daedalus-stub/src/mcp.rs @@ -212,6 +212,10 @@ mod tests { #[test] fn tools_call_runs_echo_tool() { + // The stub's process supervisor reaps ANY child (`waitpid(-1, ...)`), + // so a concurrent fork-based test would steal this `cat` child and we + // would get ECHILD. Serialize with the other child-spawning tests. + let _guard = crate::exec::FORK_TEST_LOCK.lock().unwrap(); let line = handle_line( r#"{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"echo","arguments":{"x":1}}}"#, &tools(), diff --git a/daedalus-stub/src/win.rs b/daedalus-stub/src/win.rs index 293c694..69296db 100644 --- a/daedalus-stub/src/win.rs +++ b/daedalus-stub/src/win.rs @@ -188,8 +188,8 @@ pub fn spawn( flags, env_block.as_ptr() as *mut c_void, cwd_wide.as_ref().map_or(std::ptr::null(), Vec::as_ptr), - &mut si, - &mut pi, + &raw mut si, + &raw mut pi, ) }; @@ -252,7 +252,7 @@ pub fn try_wait(child: &Child) -> io::Result> { fn exit_code(child: &Child) -> io::Result { let mut code: u32 = 0; // SAFETY: GetExitCodeProcess writes the exit code after success. - let rc = unsafe { GetExitCodeProcess(child.handle, &mut code) }; + let rc = unsafe { GetExitCodeProcess(child.handle, &raw mut code) }; if rc == 0 { return Err(io::Error::new( io::ErrorKind::Other, diff --git a/daedalus-stub/tests/chaos_monkey.rs b/daedalus-stub/tests/chaos_monkey.rs index e1a8f68..18aac8b 100644 --- a/daedalus-stub/tests/chaos_monkey.rs +++ b/daedalus-stub/tests/chaos_monkey.rs @@ -1,4 +1,5 @@ #![allow(missing_docs)] +#![cfg(target_os = "linux")] //! Chaos-monkey suite for the daedalus stub launcher. //! //! Philosophy: every test feeds the launcher hostile input — truncated binaries, diff --git a/daedalus-stub/tests/e2e_sisr/mock_server.rs b/daedalus-stub/tests/e2e_sisr/mock_server.rs index 98f1470..2f24b47 100644 --- a/daedalus-stub/tests/e2e_sisr/mock_server.rs +++ b/daedalus-stub/tests/e2e_sisr/mock_server.rs @@ -85,6 +85,9 @@ impl Drop for MockHttpServer { /// and replies with the body (or 404). `Connection: close` keeps the protocol /// simple — the client reconnects per request. fn serve(stream: &mut TcpStream, routes: &Arc>>>) { + // macOS (unlike Linux) accepts with the listener's nonblocking flag set, + // so the read below can spuriously EAGAIN and the request is dropped. + let _ = stream.set_nonblocking(false); let path = match read_request_path(stream) { Some(p) => p, None => return, diff --git a/scripts/install.sh b/scripts/install.sh index e3cc76a..38207f5 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -19,13 +19,14 @@ detect_platform() { arch="$(uname -m)" case "$os" in - Linux*) os="linux" ;; - Darwin*) os="macos" ;; - *) err "unsupported OS: $os" ;; + Linux*) os="linux" ;; + Darwin*) os="darwin" ;; + MINGW*|MSYS*|CYGWIN*) os="windows" ;; + *) err "unsupported OS: $os" ;; esac case "$arch" in - x86_64|amd64) arch="x64" ;; + x86_64|amd64) arch="amd64" ;; aarch64|arm64) arch="arm64" ;; *) err "unsupported architecture: $arch" ;; esac @@ -71,13 +72,16 @@ main() { platform="$(detect_platform)" info "detected platform: ${platform}" - # Get latest version from GitHub API + # Get latest version from GitHub API. + # `cut -d'"' -f4` on `"tag_name": "v0.7.0",` yields `v0.7.0`. Prefer it over + # a sed regex: BSD sed (macOS) does not support GNU `\?` quantifiers, so the + # sed variant returned the whole line and produced a malformed download URL. if command -v curl &>/dev/null; then version="$(curl -fsSL "https://api.github.com/repos/${REPO}/releases/latest" \ - | grep '"tag_name"' | head -1 | sed 's/.*"tag_name": *"v\?\([^"]*\)".*/\1/')" + | grep '"tag_name"' | head -1 | cut -d'"' -f4)" elif command -v wget &>/dev/null; then version="$(wget -qO- "https://api.github.com/repos/${REPO}/releases/latest" \ - | grep '"tag_name"' | head -1 | sed 's/.*"tag_name": *"v\?\([^"]*\)".*/\1/')" + | grep '"tag_name"' | head -1 | cut -d'"' -f4)" else err "curl or wget required" fi @@ -86,6 +90,9 @@ main() { err "could not determine latest version" fi + # The API reports the tag WITH its leading `v`; asset names do NOT carry it + # (e.g. `daedalus_0.7.0_darwin_arm64.tar.gz`). + version="${version#v}" tag="v${version}" info "latest version: ${version}" @@ -104,7 +111,11 @@ main() { tmpdir="$(mktemp -d)" trap 'rm -rf "${tmpdir}"' EXIT - asset="daedalus-${platform}.tar.gz" + local os arch plat_dir + os="${platform%-*}" + arch="${platform#*-}" + plat_dir="daedalus_${version}_${os}_${arch}" + asset="${plat_dir}.tar.gz" url="${GITHUB}/releases/download/${tag}/${asset}" info "downloading ${asset}..." @@ -131,9 +142,9 @@ main() { info "extracting..." tar xzf "${tmpdir}/${asset}" -C "${tmpdir}" - local extracted_dir="${tmpdir}/daedalus-${platform}" + local extracted_dir="${tmpdir}/${plat_dir}" if [ ! -d "$extracted_dir" ]; then - extracted_dir="$(find "${tmpdir}" -maxdepth 1 -type d -name 'daedalus-*' | head -1)" + extracted_dir="$(find "${tmpdir}" -maxdepth 1 -type d -name 'daedalus_*' | head -1)" fi if [ ! -d "$extracted_dir" ]; then From e75c97a0959eb5b63f3c7c6a2ffff301694da00d Mon Sep 17 00:00:00 2001 From: Tednoob17 Date: Thu, 17 Sep 2026 09:40:07 +0100 Subject: [PATCH 02/11] fix: run Java jlink embed on macOS + fix macOS smoke test path --- .github/workflows/macos-test.yml | 3 ++- daedalus-cli/src/commands/build/jlink.rs | 8 +++++++ daedalus-core/src/embed.rs | 28 ++++++++++++++---------- 3 files changed, 26 insertions(+), 13 deletions(-) diff --git a/.github/workflows/macos-test.yml b/.github/workflows/macos-test.yml index c90ae91..36b762b 100644 --- a/.github/workflows/macos-test.yml +++ b/.github/workflows/macos-test.yml @@ -49,7 +49,8 @@ jobs: - name: Smoke test (x86_64) run: | - /tmp/daedalus-stub-target/release/daedalus build examples/hello-web --isolation none -o /tmp/hello-web.de 2>&1 + DAEDALUS_STUB_PATH=target/x86_64-apple-darwin/release/daedalus-stub \ + target/x86_64-apple-darwin/release/daedalus build examples/hello-web --isolation none -o /tmp/hello-web.de 2>&1 /tmp/hello-web.de & PID=$! sleep 5 diff --git a/daedalus-cli/src/commands/build/jlink.rs b/daedalus-cli/src/commands/build/jlink.rs index 68d52d4..dcf4137 100644 --- a/daedalus-cli/src/commands/build/jlink.rs +++ b/daedalus-cli/src/commands/build/jlink.rs @@ -288,6 +288,7 @@ pub fn fixup_jre_launcher(rootfs: &Path) -> Result<()> { ) })?; } + #[cfg(target_os = "linux")] embed_image_deps(&image, rootfs)?; let bin_java = rootfs.join("usr/bin/java"); @@ -311,6 +312,12 @@ pub fn fixup_jre_launcher(rootfs: &Path) -> Result<()> { /// launcher reaches the PATH via the `/usr/bin/java` symlink, `$ORIGIN` is /// `/usr/bin`, so its RUNPATH (`$ORIGIN/../lib`) misses the image lib dir — /// but the stub's `LD_LIBRARY_PATH` includes /usr/lib. +/// +/// Linux-only: it embeds ELF/glibc system libs into a bare pivot_root tree. +/// On macOS there is nothing to embed — the launcher finds libjvm relative to +/// its real path (`java.home`) inside the image and system dylibs resolve from +/// the dyld shared cache (and `ldd` does not even exist there). +#[cfg(target_os = "linux")] fn embed_image_deps(image: &Path, rootfs: &Path) -> Result<()> { let mut binaries = vec![image.join("bin/java")]; let mut image_libs = BTreeSet::new(); @@ -354,6 +361,7 @@ fn embed_image_deps(image: &Path, rootfs: &Path) -> Result<()> { /// (`/lib/x86_64-linux-gnu`) which is NOT in the pivot-root /// `LD_LIBRARY_PATH` — only `/usr/lib/x86_64-linux-gnu` is. The dynamic /// loader (`ld-linux`) keeps its absolute interp path. +#[cfg(target_os = "linux")] fn copy_ldd_deps(binary: &Path, rootfs: &Path) -> Result<()> { let output = Command::new("ldd") .arg(binary) diff --git a/daedalus-core/src/embed.rs b/daedalus-core/src/embed.rs index d6e23e5..e68adb3 100644 --- a/daedalus-core/src/embed.rs +++ b/daedalus-core/src/embed.rs @@ -195,14 +195,16 @@ pub fn find_interpreter_host(name: &str) -> Option { pub(crate) fn ldd_deps(interp_path: &Path) -> io::Result> { // `ldd` is a Linux ELF tool. On Windows the interpreter (e.g. the official // node.exe) is a self-contained PE that resolves its DLLs via PATH and the - // system dirs, so there is nothing to embed here. Return empty instead of - // aborting the whole embed. - #[cfg(not(unix))] + // system dirs, so there is nothing to embed here. macOS Mach-O binaries + // resolve against the dyld shared cache, whose dylibs must not be copied. + // Only Linux rootfs needs these `.so` dependencies embedded. Return empty + // instead of aborting the whole embed on the other hosts. + #[cfg(not(target_os = "linux"))] { let _ = interp_path; Ok(Vec::new()) } - #[cfg(unix)] + #[cfg(target_os = "linux")] { let output = Command::new("ldd") .arg(interp_path) @@ -259,7 +261,7 @@ pub(crate) fn ldd_deps(interp_path: &Path) -> io::Result> { /// Extract the ELF interpreter (dynamic loader) path from a single `ldd` /// output line. The loader line has no `=>`, e.g. /// `/lib64/ld-linux-x86-64.so.2 (0x00007f...)`. -#[cfg(unix)] +#[cfg(target_os = "linux")] fn parse_loader_line(line: &str) -> Option { let line = line.trim(); if !line.starts_with('/') || line.contains("=>") { @@ -277,14 +279,16 @@ fn parse_loader_line(line: &str) -> Option { /// `/lib64/ld-linux-x86-64.so.2`). Without it in the rootfs, the kernel /// cannot exec the embedded binary under `pivot_root` isolation. fn elf_interpreter_path(interp_path: &Path) -> io::Result> { - #[cfg(not(unix))] + // Only Linux ELF binaries have an interpreter (`ld-linux`/`ld-musl`) that + // must be present in the rootfs for `pivot_root` to exec them. Windows PE + // and macOS Mach-O resolve their loaders from PATH and the dyld shared + // cache respectively, so there is nothing to embed. + #[cfg(not(target_os = "linux"))] { let _ = interp_path; - // No ELF dynamic loader on Windows; the interpreter is a self-contained - // PE that resolves its DLLs via PATH/system dirs. Ok(None) } - #[cfg(unix)] + #[cfg(target_os = "linux")] { let output = match Command::new("ldd").arg(interp_path).output() { Ok(o) => o, @@ -1510,7 +1514,7 @@ mod tests { use super::*; #[test] - #[cfg(unix)] + #[cfg(target_os = "linux")] /// `parse_loader_line_glibc` - parse loader line glibc. /// /// Description: @@ -1525,7 +1529,7 @@ mod tests { } #[test] - #[cfg(unix)] + #[cfg(target_os = "linux")] /// `parse_loader_line_musl` - parse loader line musl. /// /// Description: @@ -1540,7 +1544,7 @@ mod tests { } #[test] - #[cfg(unix)] + #[cfg(target_os = "linux")] /// `parse_loader_line_rejects_regular_deps` - parse loader line rejects regular deps. /// /// Description: From a6d4997ab83dd2dd296191733885ba86bae5444a Mon Sep 17 00:00:00 2001 From: Tednoob17 Date: Thu, 17 Sep 2026 11:33:03 +0100 Subject: [PATCH 03/11] feat: metadata templates (--template application|service|plugin) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Discovery metadata field meta["template"] records the artifact kind (application, service, plugin) for host-app/tooling consumption. Changes: - Core: AppTemplate enum + serialization into meta["template"] - CLI: --template flag (clap ValueEnum), default application - CLI: wiring into MetaOptions in pipeline - Core: 14 assembly tests (inc. default + service serialization) - CLI: 1 test (TemplateArg→AppTemplate conversion) - Roadmaps: mark Phase 6 templates done --- ROADMAP.md | 30 ++-- daedalus-cli/src/commands/build/args.rs | 47 ++++++ daedalus-cli/src/commands/build/pipeline.rs | 1 + daedalus-core/src/assembly.rs | 29 ++++ docs/ROADMAP.md | 157 +------------------- docs/src/roadmap.md | 12 +- 6 files changed, 109 insertions(+), 167 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 4b1a7dd..19de499 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,18 @@ # Roadmap +Single source of truth for daedalus planning. `docs/ROADMAP.md` points here; +`docs/src/roadmap.md` is the public summary for the website. + +## Current status + +Cross-platform CI is the open item: Linux CI is green; Windows/macOS builds and +checks were fixed in `2ab5d7b` + `e75c97a` (Linux-only ELF helper gating, macOS +Java/jlink embed, workflow paths). The remaining validation is watching the +post-push GitHub Actions run go green on `windows-check`, `cross +x86_64-pc-windows-gnu`, `native-macos` and the cross-platform smoke tests. +Product-wise: runtimes are all production-grade on Linux, Java JRE embed +(jlink) now works on macOS, adoption (demo, hub, installers) has not started. + ## Runtimes ### Complete / production-ready @@ -11,7 +24,7 @@ - Perl (Mojolicious, single-file `Mojo::` apps) - Electron (cross-arch/OS binary embed, `resources/` bundled) - Go (static binary, cross-compile) -- Rust (cargo build, auto-download toolchain) +- Rust (cargo build, auto-download toolchain; auto-downloads `rustup-init` + installs stable into `~/.cache` when no system cargo/rustup) - .NET/C# (self-contained, cross-RID) - Binary (ELF/PE staging) - Deno (toolchain download + deno cache) @@ -19,14 +32,8 @@ - Wasm (wasmtime embed) - Ollama (detection + `ollama serve` entrypoint) - Gemma (offline `.gguf` bundling via `--model`, `ollama run `, no cloud/GPU) - -### Partial — needs work - -| Runtime | Gap | Priority | -|---------|-----|----------| -| Rust | ✅ Auto-downloads `rustup-init` + installs stable into `~/.cache` when no system cargo/rustup | ~~P1~~ | -| Electron | ✅ Cross-OS/arch binary embed (OS-aware `is_cross`, `resources/` embedded beside binary) | ~~P2~~ | -| Perl | ✅ Mojolicious-specific detection added (script/ + lib/.pm layout, `Mojo::` imports) | ~~P3~~ | +- Perl (Mojolicious-specific detection: script/ + lib/ layout, `Mojo::` imports) +- Electron (cross-OS/arch binary embed, OS-aware `is_cross`, `resources/` embedded beside binary) ### Missing — planned @@ -73,7 +80,7 @@ | Encryption (AES-256-GCM) | Done | | Ed25519 signing | Done | | Squashfs payload | Done | -| jlink minimal JRE | Not started | +| jlink minimal JRE | Done | | Build cache | Done | | Parallel multi-target | Done | | Universal binary (`--universal`) | Done (polyglot shell launcher, multi-arch slices) | @@ -81,6 +88,7 @@ | Registry CAS (`daedalus registry push/pull/list`) | Done | | Lazy loading (`--lazy-load`) | Done (priority extraction + background thread) | | Multi-service build (`--entrypoint service=cmd`) | Done | +| Metadata templates (`--template application|service|plugin`) | Done | ## Security @@ -93,7 +101,7 @@ | Ed25519 bit validation (CVE-2023-48022) | Done | | SISR publisher signature | Done | | Capability-based sandboxing (seccomp + Landlock) | Done | -| At-rest authenticity | Roadmap #45 | +| At-rest authenticity (SISR manifest + Ed25519 checked at cold start) | Done | ## Product & adoption diff --git a/daedalus-cli/src/commands/build/args.rs b/daedalus-cli/src/commands/build/args.rs index 3541c3d..30c1a11 100644 --- a/daedalus-cli/src/commands/build/args.rs +++ b/daedalus-cli/src/commands/build/args.rs @@ -107,6 +107,27 @@ pub(crate) enum GpuArg { None, } +/// Artifact kind for `--template`; recorded as metadata only. +#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)] +pub(crate) enum TemplateArg { + /// Runnable user-facing app (web server, CLI, agent) + Application, + /// Background worker/daemon running alongside services + Service, + /// Extension consumed by a host application + Plugin, +} + +impl From for daedalus_core::assembly::AppTemplate { + fn from(value: TemplateArg) -> Self { + match value { + TemplateArg::Application => Self::Application, + TemplateArg::Service => Self::Service, + TemplateArg::Plugin => Self::Plugin, + } + } +} + /// A named service parsed from `--entrypoint name=cmd,arg1,...`. /// /// Merged with `--service-port`/`--service-timeout` overrides so the @@ -617,6 +638,12 @@ pub struct BuildArgs { #[arg(long)] pub license: Option, + /// Artifact kind recorded in the metadata (`application`, `service`, + /// `plugin`). Discovery metadata for tooling/host apps — it does not + /// change the binary layout. Defaults to `application`. + #[arg(long, value_enum)] + pub template: Option, + /// Dry run — show what would be built without building #[arg(long)] pub dry_run: bool, @@ -871,6 +898,7 @@ pub(crate) fn default_build_args() -> BuildArgs { author: None, description: None, license: None, + template: None, dry_run: false, update: false, include: Vec::new(), @@ -914,6 +942,25 @@ pub(crate) fn default_build_args() -> BuildArgs { mod tests { use super::*; + #[test] + /// `template_arg_maps_to_core_template` - template arg maps to core template. + /// + /// Description: + /// + /// Return: nothing + fn template_arg_maps_to_core_template() { + use daedalus_core::assembly::AppTemplate; + assert_eq!( + AppTemplate::from(TemplateArg::Application), + AppTemplate::Application + ); + assert_eq!( + AppTemplate::from(TemplateArg::Service), + AppTemplate::Service + ); + assert_eq!(AppTemplate::from(TemplateArg::Plugin), AppTemplate::Plugin); + } + #[test] /// sandbox_default_maps_to_level_2 - sandbox default maps to level 2. /// diff --git a/daedalus-cli/src/commands/build/pipeline.rs b/daedalus-cli/src/commands/build/pipeline.rs index 0417d60..613583c 100644 --- a/daedalus-cli/src/commands/build/pipeline.rs +++ b/daedalus-cli/src/commands/build/pipeline.rs @@ -306,6 +306,7 @@ pub(crate) fn build_single_target( &env_pairs, &daedalus_core::assembly::MetaOptions { version: version_info, + template: args.template.map(Into::into).unwrap_or_default(), author, description, license, diff --git a/daedalus-core/src/assembly.rs b/daedalus-core/src/assembly.rs index 34037bb..8c9f4ec 100644 --- a/daedalus-core/src/assembly.rs +++ b/daedalus-core/src/assembly.rs @@ -145,6 +145,11 @@ pub fn build_meta_json( apply_meta_options(&mut meta, options)?; + // Every artifact declares a template so tooling can classify a `.de` + // without executing it (`inspect` surfaces it; the stub ignores the key). + meta["template"] = serde_json::to_value(options.template) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; + if bun_features.health_check.enabled { meta["health_check"] = serde_json::to_value(&bun_features.health_check) .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; @@ -256,9 +261,27 @@ pub struct ServiceSpec { pub ready_timeout: u64, } +/// Kind of packaged artifact, recorded in the metadata as `template`. +/// Pure discovery metadata for tooling and host apps — it documents the +/// intended lifecycle of a `.de` (or one of its services) but never changes +/// the binary layout or the stub's execution. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize)] +#[serde(rename_all = "lowercase")] +pub enum AppTemplate { + /// Runnable, user-facing app (web server, CLI, agent). + #[default] + Application, + /// Background worker/daemon that runs alongside latency-critical services. + Service, + /// Extension consumed by a host application; no standalone entrypoint. + Plugin, +} + /// Options for metadata construction. #[allow(clippy::struct_excessive_bools)] pub struct MetaOptions { + /// Artifact kind recorded in `meta["template"]` for tooling/hosts. + pub template: AppTemplate, pub version: Option, pub author: Option, pub description: Option, @@ -685,6 +708,7 @@ mod tests { fn build_meta_json_produces_valid_json() { let opts = MetaOptions { version: Some("1.0".into()), + template: AppTemplate::default(), author: None, description: None, license: None, @@ -724,6 +748,7 @@ mod tests { assert_eq!(parsed["name"], "myapp"); assert_eq!(parsed["runtime"], "python"); assert_eq!(parsed["version"], "1.0"); + assert_eq!(parsed["template"], "application"); // Layers should be populated with a default RuntimeLayer assert!(parsed["layers"].is_array()); let layers = parsed["layers"].as_array().unwrap(); @@ -748,6 +773,7 @@ mod tests { fn services_serialize_into_metadata() { let opts = MetaOptions { version: None, + template: AppTemplate::Service, author: None, description: None, license: None, @@ -790,6 +816,7 @@ mod tests { ) .expect("meta serialization failed"); let parsed: serde_json::Value = serde_json::from_slice(&json).unwrap(); + assert_eq!(parsed["template"], "service"); let services = parsed["services"].as_array().expect("services array"); assert_eq!(services.len(), 1); assert_eq!(services[0]["name"], "api"); @@ -808,6 +835,7 @@ mod tests { fn gpu_backend_serializes_into_metadata() { let opts = MetaOptions { version: None, + template: AppTemplate::Service, author: None, description: None, license: None, @@ -875,6 +903,7 @@ mod tests { fn lazy_priority_serializes_into_metadata() { let opts = MetaOptions { version: None, + template: AppTemplate::Service, author: None, description: None, license: None, diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index e4fa9b0..db8fbf8 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,153 +1,10 @@ -# daedalus — ROADMAP (docs) +# Roadmap (docs) -## Vision +The roadmap lives in a single file: [`ROADMAP.md`](../ROADMAP.md) at the repo +root is the canonical, living planning doc. -daedalus est un **format d'artefact exécutable universel**, capable de transporter : +The public summary for the website is [`docs/src/roadmap.md`](./src/roadmap.md) +(mdbook), which mirrors the root doc's statuses. -- une application web/server/CLI ✅ -- un service/microservice — objectif -- un plugin/extension — objectif - -> Le format est conçu comme une unité autonome = stub + runtime + payload + metadata + signature. -> Rien n'est hérité du système hôte. - ---- - -## Use case clé : Universal Application Packaging - -Une application web est un binaire daedalus qui contient : - -``` -[stub][runtime (python3)][code (app/)][config (daedalus.toml)][deps (site-packages/)] -``` - -**Ce que le format permet aujourd'hui :** -- Packager runtime + code + config dans un seul binaire portable -- Signature Ed25519 pour vérifier l'origine -- Chiffrement AES-256-GCM pour protéger le payload -- SISR pour update delta sans tout re-télécharger - -**Ce qui manque :** -- **Hot-swap de couches** : remplacer la couche `RuntimeLayer` (nouveau runtime) sans extraire le code. -- **Lazy loading** : ne charger les gros assets que lorsqu'ils sont appelés. -- **Multi-service** : un seul binaire contenant 2-3 services avec un entrypoint par service. - -> Sans intégration des couches dans le stub, l'hot-swap et le lazy loading ne sont pas possibles. - ---- - -## État actuel (août 2026) - -### Couche cœur (`daedalus-core`) -- ✅ CAS — `cas.rs` : `ObjectStore` trait, `MemoryStore`, `DiskObjectStore` -- ✅ Format binaire — `format.rs` : v2 (plain), v3 (signed), v4 (encrypted), v5 (squashfs) -- ✅ Metadata — `metadata.rs` : `Metadata` avec runtime, entrypoint, layers -- ✅ Signature Ed25519 — `crypto.rs` -- ✅ Chiffrement AES-256-GCM — `encrypt.rs` -- ✅ Détection runtime — `detect.rs` : 11 runtimes, `EntrypointRegistry` - -### Abstraction des couches (`layer.rs`) -- ✅ Types concrets : `RuntimeLayer`, `ConfigLayer` -- ✅ `Capability` enum : `ReadFile`, `WriteFile`, `Network`, `Exec`, `Syscall`, `Env` -- ✅ `LayerKind` : `Runtime`, `Config`, `Custom` -- ✅ `LayerManifest` : tracking des couches dans le cache du stub - -### SISR / Delta updates (`sisr/`) -- ✅ `SisrEngine` : réutilisation de chunks, vérification SHA-256, swap atomique -- ✅ `HealthStore` : quarantaine, suivi des échecs -- ✅ `AtomicWriter` : remplacement atomique de fichiers -- ✅ Tests réseau avec injection de fautes - -### Assemblage -- ✅ `assemble_daedalus` : builder unifié avec `AtomicWriter`, aware SISR -- ✅ Manifest distant : `.daedalus.manifest` - -### CLI (`daedalus-cli`) -- ✅ `build`, `inspect`, `scan`, `sign`, `verify`, `keygen`, `trust` -- ✅ `doctor`, `env`, `clean`, `completion`, `man`, `upgrade` -- ✅ `registry push/pull/list` — content-addressable layer sharing -- ✅ `swap` — hot-swap layers without rebuild -- ✅ `publish` — publish layers to local/remote CAS after build -- ✅ JRE minimal embarqué par `jlink` par défaut (module closure via `jdeps`), échappements `--full-jre` / `--jlink-modules` — artefact Java auto-suffisant, sans `java` hôte - -### Stub launcher (`daedalus-stub`) -- ✅ Lecture footer/metadata depuis `/proc/self/exe` -- ✅ Cache SHA-256, vérification d'intégrité -- ✅ Extraction zstd+tar ou squashfs -- ✅ `execvp` entrypoint -- ✅ SISR delta update -- ✅ Layer manifest tracking (cache-aware warm start) -- ✅ Capability-based sandboxing (seccomp + Landlock) -- ✅ Authenticité at-rest : signature Ed25519 et manifeste SISR vérifiés au démarrage à froid — refus d'exécution si la clé n'est pas dans l'ancre de confiance utilisateur -- ✅ Politique symlink de l'extraction : symlinks relatifs in-root autorisés, tout absolu/cassé rejeté - ---- - -## Ce qui reste à faire - -### Phase 1 : Universal Binary (bloquant) - -**Objectif** : Un `.daedalus` marche partout (x64/ARM64, Linux/macOS/Windows). - -1. `--universal` flag → build matrix via `cargo zigbuild` pour chaque OS/arch -2. Assemble slices dans wrapper polyglot (MZ+ELF+Mach-O overlap header) -3. Runtime : detect `uname -s`/`uname -m` → extract right slice → `execve` - -**Status** : Cross-compilation validée (seccomp.rs fix). Wrapper polyglot à implémenter. - -### Phase 2 : Hot-swap (3-4 jours) - -**Objectif** : Remplacer une couche sans rebuild complet du binaire. - -1. `daedalus swap ` -2. SISR-aware : si activé, génère un delta update - -### Phase 3 : Lazy loading (4-5 jours) - -**Objectif** : Ne charger que les couches nécessaires, à la demande. - -1. Mapping mémoire des couches — mmap le payload extrait -2. Montage FUSE optionnel — monter le rootfs sans extraction complète - -### Phase 4 : Registry CAS (3-4 jours) - -**Objectif** : Publier/charger des couches via un registre distant. - -1. `Registry` dans `daedalus-core` au-dessus de `ObjectStore` -2. CLI `daedalus registry push/pull/list` -3. Intégrer au build : `daedalus build --publish` - -### Phase 5 : Security audit + enforcement (2-3 jours) - -**Objectif** : Les `Capability` doivent être vérifiées, pas juste déclarées. - -1. Enforcer les capabilities dans le stub (seccomp + Landlock) -2. Tests de sandboxing - -### Phase 6 : Templates + multi-service (2-3 jours) - -**Objectif** : Support pour services et plugins. - -1. Templates de metadata : `application`, `service`, `plugin` -2. Multi-service : `daedalus build ./services --entrypoint api=api.py --entrypoint worker=worker.py` - ---- - -## Priorité - -1. **Phase 1** (universal binary) — **bloque cross-platform** -2. **Phase 5** (security) — **nécessaire avant production** -3. **Phase 2** (hot-swap) — **différenciant**, remplacer une couche sans rebuild -4. **Phase 3** (lazy loading) — **performance**, ne pas charger gros assets au démarrage -5. **Phase 4** (registry) — **adoption**, partager des couches entre artefacts -6. **Phase 6** (templates) — **storytelling**, prouve que le format est universel - ---- - -## Contraintes techniques - -- **vfat** : le repo vit sur vfat (pas de bit exec). Les artefacts build vont dans `/tmp/daedalus-stub-target`. -- **musl** : le stub compile avec `--target x86_64-unknown-linux-musl` pour un ELF statique. -- **CI** : clippy est lancé par crate, pas workspace-wide. -- **Edition** : Rust 2021, stable uniquement (pas de nightly). -- **Sécurité** : zero `unsafe` dans `daedalus-core` et `daedalus-cli`. Toute la logique unsafe est dans `daedalus-stub`. +This file is retired to avoid three divergent roadmaps. Update `ROADMAP.md` +(root), then mirror any user-facing change into `docs/src/roadmap.md`. \ No newline at end of file diff --git a/docs/src/roadmap.md b/docs/src/roadmap.md index 7d6c6cc..286fe76 100644 --- a/docs/src/roadmap.md +++ b/docs/src/roadmap.md @@ -86,17 +86,17 @@ ni Node, ni quoi que ce soit. - [x] `.lazy_done` marker when background extraction completes - [x] Works with zstd+tar payloads (squashfs falls back to full extraction) -## Phase 6 — Templates + multi-service (PARTIAL) +## Phase 6 — Templates + multi-service ✅ - [x] Champ `services` dans metadata, détection mode serveur dans `daedalus selftest` -- [ ] Build multi-service : `daedalus build ./services --entrypoint api=api.py --entrypoint worker=worker.py` -- [ ] Templates de metadata : `application`, `service`, `plugin` +- [x] Build multi-service : `daedalus build ./services --entrypoint service=cmd` +- [x] Templates de metadata : `daedalus build --template application|service|plugin`, recordé dans `meta["template"]` pour le discovery par les outils/hôtes ## Priorité -1. **Phase 5** (lazy loading) — **performance**, ne pas charger gros assets au démarrage -2. **Phase 6** (templates + multi-service) — **storytelling**, prouve que le format est universel -3. **Registry CAS** — **adoption**, partager des couches entre artefacts (`daedalus registry push/pull/list` déjà implémenté) +1. **Cross-platform CI** — prouver « ça tourne partout » : windows-check, cross windows-gnu, native-macos, smoke tests (2ab5d7b + e75c97a corrigent les dernières races) +2. **Adoption** — démo 60 s, mini hub (~10 apps), installateurs `brew`/`pip`/`curl` +3. **Runtimes supplémentaires** — Dart/Flutter et Zig d'abord (écosystème dev + single binary) ## Ce qui n'est PAS prioritaire From eec8684b211d9dd3e6e0872af186ae9ebc693849 Mon Sep 17 00:00:00 2001 From: Tednoob17 Date: Thu, 17 Sep 2026 11:33:08 +0100 Subject: [PATCH 04/11] fix(deps): bump rustls 0.23.45 for RUSTSEC-2026-0285 TLS 1.3 handshake messages were accepted across encryption level boundaries below 0.23.45. cargo audit now passes. --- Cargo.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 97dc790..3175e48 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2923,9 +2923,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.43" +version = "0.23.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" dependencies = [ "once_cell", "ring", @@ -3349,7 +3349,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.3", + "getrandom 0.3.4", "once_cell", "rustix 1.1.4", "windows-sys 0.61.2", From 60625dd2cb9e4ff3e8ec2ab90912b5e56115e220 Mon Sep 17 00:00:00 2001 From: Tednoob17 Date: Thu, 17 Sep 2026 11:33:12 +0100 Subject: [PATCH 05/11] fix(ci): native macOS smoke + Windows smoke hardening - macOS: smoke test now builds+runs native (aarch64) artifacts instead of x86_64-under-Rosetta; adds a 20s retry poll and server-log dump on failure so the step is debuggable when it does break. - ci.yml: Windows smoke creates C:\tmp (did not exist), polls for 30s instead of fixed 5s. --- .github/workflows/ci.yml | 24 +++++++++++++--------- .github/workflows/macos-test.yml | 35 +++++++++++++++++++++++++------- 2 files changed, 42 insertions(+), 17 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2740d14..168e4ac 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -263,19 +263,23 @@ jobs: - name: Smoke test Windows x86_64 shell: pwsh run: | + New-Item -ItemType Directory -Force -Path C:\tmp | Out-Null $env:DAEDALUS_STUB_PATH = ".\artifacts\daedalus-stub.exe" .\artifacts\daedalus.exe build examples\hello-web --isolation none -o C:\tmp\hello-web.daedalus --embed-interpreter python - Start-Process -FilePath "C:\tmp\hello-web.daedalus" -ArgumentList "" -WindowStyle Hidden - $proc = Get-Process -Name "hello-web" -ErrorAction SilentlyContinue - Start-Sleep -Seconds 5 - try { - $resp = Invoke-WebRequest -Uri "http://127.0.0.1:8080" -UseBasicParsing -ErrorAction Stop - if ($resp.Content -match "Hello") { - Write-Host "Windows x86_64 smoke test passed" - } else { - throw "Unexpected response" + Start-Process -FilePath "C:\tmp\hello-web.daedalus" -WindowStyle Hidden + $deadline = (Get-Date).AddSeconds(30) + $resp = $null + while ((Get-Date) -lt $deadline) { + try { + $resp = Invoke-WebRequest -Uri "http://127.0.0.1:8080" -UseBasicParsing -ErrorAction Stop + break + } catch { + Start-Sleep -Seconds 2 } - } catch { + } + if ($resp -and $resp.Content -match "Hello") { + Write-Host "Windows x86_64 smoke test passed" + } else { Write-Host "Windows x86_64 smoke test failed" Stop-Process -Name "hello-web" -Force -ErrorAction SilentlyContinue exit 1 diff --git a/.github/workflows/macos-test.yml b/.github/workflows/macos-test.yml index 36b762b..a0f6914 100644 --- a/.github/workflows/macos-test.yml +++ b/.github/workflows/macos-test.yml @@ -32,6 +32,12 @@ jobs: target/ key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + - name: Build CLI (native) + run: cargo build --release -p daedalus-cli + + - name: Build stub (native) + run: cargo build --release -p daedalus-stub + - name: Build CLI (x86_64) run: cargo build --release -p daedalus-cli --target x86_64-apple-darwin @@ -47,15 +53,30 @@ jobs: - name: Run tests run: cargo test --workspace - - name: Smoke test (x86_64) + - name: Smoke test (native) run: | - DAEDALUS_STUB_PATH=target/x86_64-apple-darwin/release/daedalus-stub \ - target/x86_64-apple-darwin/release/daedalus build examples/hello-web --isolation none -o /tmp/hello-web.de 2>&1 - /tmp/hello-web.de & + set -x + DAEDALUS_STUB_PATH=target/release/daedalus-stub \ + target/release/daedalus build examples/hello-web --isolation none -o /tmp/hello-web.de 2>&1 + ls -la /tmp/hello-web.de + /tmp/hello-web.de > /tmp/hello-web.log 2>&1 & PID=$! - sleep 5 - curl -sf http://127.0.0.1:8080 | grep -q "Hello" && echo "smoke test passed" || { echo "smoke test failed"; kill $PID; exit 1; } - kill $PID || true + ok="" + for i in 1 2 3 4 5 6 7 8 9 10; do + if curl -sf --max-time 2 http://127.0.0.1:8080 | grep -q "Hello"; then + ok=1 + echo "smoke test passed" + break + fi + sleep 2 + done + cat /tmp/hello-web.log || true + if [ -z "$ok" ]; then + echo "smoke test failed" + kill $PID 2>/dev/null || true + exit 1 + fi + kill $PID 2>/dev/null || true - name: Verify artifacts run: | From 045417fd3739984352d9fdb1f906d499b7742ddd Mon Sep 17 00:00:00 2001 From: Tednoob17 Date: Thu, 17 Sep 2026 13:03:04 +0100 Subject: [PATCH 06/11] fix(cli): resolve native stub without --target or env var MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit find_stub(None) hardcoded the x86_64-unknown-linux-musl triple, so a plain `daedalus build` on macOS/Windows never found the stub that runs on this host. Now uses the host triple for no-target builds and also looks next to the running daedalus binary — where distributions install both. Native local builds (target/release) and installed archives both work with zero configuration. Also adds native_arch_suffix test. --- daedalus-cli/src/commands/build/stub.rs | 47 +++++++++++++++++++++++-- 1 file changed, 44 insertions(+), 3 deletions(-) diff --git a/daedalus-cli/src/commands/build/stub.rs b/daedalus-cli/src/commands/build/stub.rs index 7a50fe6..46c4748 100644 --- a/daedalus-cli/src/commands/build/stub.rs +++ b/daedalus-cli/src/commands/build/stub.rs @@ -10,7 +10,12 @@ use super::args::parse_target; /// 2. `target//release/daedalus-stub` (workspace build) /// 3. `/tmp/daedalus-stub-target//release/daedalus-stub` (AGENTS.md path) /// 4. `stub/target//release/daedalus-stub` (legacy layout) -/// 5. `which daedalus-stub` (system install, with warning) +/// 5. next to the running `daedalus` binary (installed distributions) +/// 6. `which daedalus-stub` (system install, with warning) +/// +/// Without a `--target`, the native host triple is used so a plain +/// `daedalus build` finds the stub that runs on this machine (macOS and +/// Windows hosts used to only look for the musl Linux stub). pub(crate) fn find_stub(target: Option<&str>) -> Result { if let Ok(path) = std::env::var("DAEDALUS_STUB_PATH") { let p = PathBuf::from(path); @@ -28,7 +33,7 @@ pub(crate) fn find_stub(target: Option<&str>) -> Result { Some((arch, os)) if os == "darwin" => format!("{arch}-apple-darwin"), Some((arch, os)) if os == "windows" => format!("{arch}-pc-windows-gnu"), Some((arch, _)) => format!("{arch}-unknown-linux-musl"), - None => String::from("x86_64-unknown-linux-musl"), + None => native_arch_suffix(), }; let stub_name = if is_windows { @@ -36,7 +41,8 @@ pub(crate) fn find_stub(target: Option<&str>) -> Result { } else { "daedalus-stub" }; - let candidates = [ + + let mut candidates = vec![ PathBuf::from(&target_dir) .join(&arch_suffix) .join("release") @@ -51,6 +57,13 @@ pub(crate) fn find_stub(target: Option<&str>) -> Result { .join(stub_name), ]; + // Installed distributions ship daedalus-stub next to the daedalus binary. + if let Ok(exe) = std::env::current_exe() { + if let Some(dir) = exe.parent() { + candidates.push(dir.join(stub_name)); + } + } + for candidate in &candidates { if candidate.exists() { return Ok(candidate.clone()); @@ -68,6 +81,16 @@ pub(crate) fn find_stub(target: Option<&str>) -> Result { anyhow::bail!("daedalus-stub not found — run: make stub") } +/// Native stub triple for the host, so no-`--target` builds resolve the stub +/// that actually runs on this machine. +fn native_arch_suffix() -> String { + match std::env::consts::OS { + "macos" => format!("{}-apple-darwin", std::env::consts::ARCH), + "windows" => format!("{}-pc-windows-gnu", std::env::consts::ARCH), + _ => format!("{}-unknown-linux-musl", std::env::consts::ARCH), + } +} + /// Read `app_hash` and `rt_deps_hash` from an existing `.daedalus` file's metadata. pub(crate) fn read_existing_hashes(daedalus_path: &Path) -> Option<(String, String)> { use daedalus_core::format::Footer; @@ -129,4 +152,22 @@ mod tests { let result = find_stub(Some("win-x64")); assert!(result.is_err() || result.is_ok(), "should not panic"); } + + #[test] + /// `native_arch_suffix` - host triple, not the musl default, on macOS/Windows. + /// + /// Description: + /// + /// Return: nothing + fn native_arch_suffix_matches_host() { + let suffix = native_arch_suffix(); + match std::env::consts::OS { + "macos" => assert_eq!(suffix, format!("{}-apple-darwin", std::env::consts::ARCH)), + "windows" => assert_eq!(suffix, format!("{}-pc-windows-gnu", std::env::consts::ARCH)), + _ => assert_eq!( + suffix, + format!("{}-unknown-linux-musl", std::env::consts::ARCH) + ), + } + } } From b515c78b6ebfebada318cea5c161a19b359855af Mon Sep 17 00:00:00 2001 From: Tednoob17 Date: Thu, 17 Sep 2026 13:03:11 +0100 Subject: [PATCH 07/11] feat(adoption): 60s demo, one-command installers, hub catalog Executes the adoption block of the roadmap: - install.sh (Linux/macOS, POSIX, sha256-verified against release checksums, no sudo) and install.ps1 (Windows, no admin) - Homebrew formula for a future tap (packaging/homebrew/) - hub/catalog.json: 4 verified apps + 6 recipe-only rows (one template per popular runtime) + hub/build.sh rebuild script - docs: 60-second demo guide (measured 6.2s build / 1.5s first run for the offline clinic-agent), hub guide, SUMMARY + roadmap updates --- ROADMAP.md | 21 +++--- docs/src/SUMMARY.md | 2 + docs/src/guides/demo-60s.md | 68 ++++++++++++++++++ docs/src/guides/hub.md | 64 +++++++++++++++++ docs/src/roadmap.md | 11 ++- hub/build.sh | 38 ++++++++++ hub/catalog.json | 125 +++++++++++++++++++++++++++++++++ hub/catalog.schema.json | 33 +++++++++ install.ps1 | 60 ++++++++++++++++ install.sh | 105 +++++++++++++++++++++++++++ packaging/README.md | 46 ++++++++++++ packaging/homebrew/daedalus.rb | 56 +++++++++++++++ 12 files changed, 617 insertions(+), 12 deletions(-) create mode 100644 docs/src/guides/demo-60s.md create mode 100644 docs/src/guides/hub.md create mode 100755 hub/build.sh create mode 100644 hub/catalog.json create mode 100644 hub/catalog.schema.json create mode 100644 install.ps1 create mode 100755 install.sh create mode 100644 packaging/README.md create mode 100644 packaging/homebrew/daedalus.rb diff --git a/ROADMAP.md b/ROADMAP.md index 19de499..13d31f2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -5,13 +5,14 @@ Single source of truth for daedalus planning. `docs/ROADMAP.md` points here; ## Current status -Cross-platform CI is the open item: Linux CI is green; Windows/macOS builds and -checks were fixed in `2ab5d7b` + `e75c97a` (Linux-only ELF helper gating, macOS -Java/jlink embed, workflow paths). The remaining validation is watching the -post-push GitHub Actions run go green on `windows-check`, `cross -x86_64-pc-windows-gnu`, `native-macos` and the cross-platform smoke tests. -Product-wise: runtimes are all production-grade on Linux, Java JRE embed -(jlink) now works on macOS, adoption (demo, hub, installers) has not started. +Cross-platform CI is the open item: Linux CI is green; macOS native (all steps +incl. smoke) and cargo-audit are green since `60625dd`. Remaining: the two +Windows jobs (`windows-check` core test, `smoke-test-windows`) — pre-existing +failures blocked on CI log access (read-only token). Product-wise: adoption +is underway — 60 s demo guide (measured 6.2 s build / 1.5 s run), one-command +installers (`install.sh`, `install.ps1`, Homebrew formula), `hub/catalog.json` +(4 verified apps + 6 recipes); runtimes are production-grade on Linux and Java +JRE embed (jlink) works on macOS. ## Runtimes @@ -111,11 +112,11 @@ not the codecs). | Lever | Action | Status | |-------|--------|--------| -| Demo / time-to-first-value | 60 s homepage demo (Streamlit or Ollama + model): `daedalus build` → an artifact that runs on a bare machine. Key message: "it's just the file." | Not started | +| Demo / time-to-first-value | 60 s homepage demo (Streamlit or Ollama + model): `daedalus build` → an artifact that runs on a bare machine. Key message: "it's just the file." | Done (measured: 6.2 s build / 1.5 s first run for the offline clinic-agent; guide in `docs/src/guides/demo-60s.md`) | | Trust (#1) | `--sign` on by default with the dev key, `daedalus verify foo.de` in one gesture, dated "security" page + audit. Signing is the headline feature, not an option (a self-extracting binary smells like malware otherwise). | Default signing done (auto dev key + self-trust, `--skip-sign` to opt out); dated audit 2026-09-09 in SECURITY.md (strict Ed25519 verify); website security page remaining | | Niche wedge | Target distribution of agents / AI-apps to non-technical users (Ollama/Gemma use cases). A niche of 1000 frustrated devs > 100k curious. | Not started | -| Ecosystem / network effect | `daedalus hub` — community catalog of reusable packaged apps (builds on `daedalus registry`). Start with ONE template per popular runtime, not a platform. | Not started | -| Zero-friction install | `brew` / `cargo install` / `pip` / `curl` install, static signed binary every release. Install < 10 s, no compile flag needed. | cargo install OK; others to do | +| Ecosystem / network effect | `daedalus hub` — community catalog of reusable packaged apps (builds on `daedalus registry`). Start with ONE template per popular runtime, not a platform. | Started (`hub/catalog.json`: 4 verified apps + 6 recipes — one per runtime; `hub/build.sh`; guide in `docs/src/guides/hub.md`) | +| Zero-friction install | `brew` / `cargo install` / `pip` / `curl` install, static signed binary every release. Install < 10 s, no compile flag needed. | `install.sh` (Linux/macOS) + `install.ps1` (Windows) shipped (checksum-verified, no sudo); cargo install OK; brew tap + crates.io + pip remaining | | Trap to avoid | No expert-oriented docs or format benchmarks as the lead feature — adoption comes from the first task unlocked. | — | Execution order: demo (1) → default signing (2) → minimal hub with ~10 packaged apps diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index 798b865..46e16ce 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -35,6 +35,8 @@ # Guides - [Quickstart](./guides/quickstart.md) +- [The 60-second demo](./guides/demo-60s.md) +- [The hub](./guides/hub.md) - [Python](./guides/python.md) - [Node.js](./guides/node.md) - [Java](./guides/java.md) diff --git a/docs/src/guides/demo-60s.md b/docs/src/guides/demo-60s.md new file mode 100644 index 0000000..cb49c5a --- /dev/null +++ b/docs/src/guides/demo-60s.md @@ -0,0 +1,68 @@ +# The 60-second demo + +> **daedalus makes an app consumable in one gesture: `./app.de`. The user sees the artifact, not the packaging. No install, no expertise.** + +A live demo, start to finish, in under one minute. The star is an **offline AI agent** (Gemma, air-gapped, no GPU, no cloud) — the hardest thing to deploy in our industry, delivered as one file. + +## The script + +```bash +# 1. Install daedalus — one command, ~10 s +curl -fsSL https://raw.githubusercontent.com/Encapsul/daedalus/main/install.sh | sh + +# 2. Package the app — ~6 s +daedalus build ./examples/offline-health-agri -o clinic-agent.de + +# 3. Run it — it's just the file +./clinic-agent.de diagnose +``` + +Stop there. On screen (worst case): + +| Step | Wall clock | +|------|-----------| +| `install.sh` | ~8 s | +| `daedalus build` → 16.9 MB artifact | ~6.2 s | +| cold first run (`diagnose`) | ~1.5 s | +| warm run (`crop`) | ~0.15 s | + +## What just happened (the talk track) + +- **`clinic-agent.de` is self-extracting.** The stub launcher carries the app, + the Python stdlib, and the Gemma model recipe in one executable. The payload + is integrity-checked with SHA-256 before anything runs, and the "app" part + can be signed with Ed25519 (`daedalus sign`, default-on dev key). +- **It runs offline.** `diagnose` / `crop` use the standard library only and + degrade gracefully when Ollama is absent — exactly the rural-clinic and + agricultural-cooperative use case the example targets. +- **Updates don't reship the file.** `daedalus` content-defined chunking only + transmits changed model chunks (measured ~90% bandwidth saved on a model + update) — see [SISR](./incremental-updates.md). +- **It runs on a bare machine.** No Python, no `pip install`, no GPU, no codecs. + +## One-liner canned summary + +> "I took an offline AI assistant that normally needs an internet connection, +> a GPU, and a five-page setup guide, and shipped it as a single 17 MB file +> you can put on a USB stick. That file also updates itself in ~1/10th of the +> download size. And trust is built in: it verifies its own payload before +> starting, and `daedalus verify clinic-agent.de` proves who signed it." + +## Replay + +```bash +# swap the app, keep daedalus +daedalus build ./examples/hello-web -o hello-web.de +./hello-web.de & # python stdlib HTTP, no deps +curl -s http://127.0.0.1:8080 | grep Hello + +daedalus inspect clinic-agent.de # show layers + integrity +daedalus scan examples/offline-health-agri # show detected runtime +``` + +## When the room is quiet + +The whole point of packaging as a single self-verifying artifact is +**adoption**: the user executes one file. That is the difference between "let +me install Python, create a venv, pip install..." and a movie playing for the +person who just double-clicked it. See [Positioning](../concepts/positioning.md). \ No newline at end of file diff --git a/docs/src/guides/hub.md b/docs/src/guides/hub.md new file mode 100644 index 0000000..c68acff --- /dev/null +++ b/docs/src/guides/hub.md @@ -0,0 +1,64 @@ +# The hub — one template per popular runtime + +A community catalog of daedalus-packaged apps. The rule is deliberately +tight: **one canonical app per runtime**, not a platform. A dev hits the +frustration of their runtime ("pip is broken again"), sees the pattern +("one file, runs anywhere"), and reuses it. + +## Layout + +``` +hub/ + catalog.json # the catalog (one entry per app) + catalog.schema.json + build.sh # rebuild buildable apps into hub/dist/ + dist/ # build output (gitignored) +``` + +## Adding an app + +1. Add an entry to `hub/catalog.json`. +2. If the source lives in this repo, set `"buildable": true` and a `build` + array; `hub/build.sh` will rebuild it. Otherwise document the canonical + source layout under `recipe` and keep `"buildable": false`. + +Entry fields: `id`, `name`, `runtime`, `template` (`application` / +`service` / `plugin`), `description`, optional `source`, `build` or +`recipe`, `run`, `port`, `buildable`. + +```json +{ + "id": "my-app", + "name": "My App", + "runtime": "python", + "template": "application", + "description": "What it shows.", + "source": "examples/my-app", + "build": ["daedalus", "build", "./examples/my-app", "-o", "hub/dist/my-app.de"], + "run": "./my-app.de", + "port": 8080, + "buildable": true +} +``` + +## Rebuilding + +```bash +hub/build.sh # all buildable apps +hub/build.sh my-app # a single app +``` + +Output lands in `hub/dist/` (gitignored — artifacts are meant to be shipped +through `release.yml`/the registry, not committed). + +## Verified apps in the catalog + +- **hello-web** — Python stdlib HTTP, zero deps. +- **hello-node** — Node stdlib HTTP. +- **bottle-web** — Python Bottle with vendored `site-packages` (no pip/network). +- **clinic-agent** — offline Gemma AI for rural clinics (see + [The 60-second demo](./demo-60s.md)). + +Recipe-only rows cover Express, FastAPI, Spring Boot, Rails, Laravel and Go +(Gin) with the exact layout + build command to reproduce one file per +runtime. \ No newline at end of file diff --git a/docs/src/roadmap.md b/docs/src/roadmap.md index 286fe76..9dc4e6c 100644 --- a/docs/src/roadmap.md +++ b/docs/src/roadmap.md @@ -94,10 +94,17 @@ ni Node, ni quoi que ce soit. ## Priorité -1. **Cross-platform CI** — prouver « ça tourne partout » : windows-check, cross windows-gnu, native-macos, smoke tests (2ab5d7b + e75c97a corrigent les dernières races) -2. **Adoption** — démo 60 s, mini hub (~10 apps), installateurs `brew`/`pip`/`curl` +1. **Cross-platform CI** — prouver « ça tourne partout » : windows-check, cross windows-gnu, native-macos, smoke tests (2ab5d7b + e75c97a corrigent les dernières races ; cargo-audit vert depuis rustls 0.23.45, native-macos vert) — reste windows-check core test + smoke-test-windows, bloqués par l'accès aux logs CI +2. **Adoption** — démo 60 s ✅ (guide + clinic-agent mesuré : build 6,2 s / run 1,5 s), installateurs `curl`/`brew`/`powershell` (install.sh + install.ps1 + formula fournis), mini hub (~10 apps, catalog + build.sh) 3. **Runtimes supplémentaires** — Dart/Flutter et Zig d'abord (écosystème dev + single binary) +## Adoption — livré + +- **Démo 60 s** : [guide](./guides/demo-60s.md) — clinic-agent (Gemma offline) : build 6,2 s → artefact 16,9 MB → run 1,5 s à froid. +- **Installation zero-friction** : `install.sh` (Linux/macOS, sans sudo, vérifié sha256 contre `checksums.txt`), `install.ps1` (Windows), formula Homebrew dans `packaging/homebrew/daedalus.rb`. +- **Hub** : [hub/catalog.json](../hub/catalog.json) — 4 apps vérifiées (hello-web, hello-node, bottle-web, clinic-agent) + 6 recettes (Express, FastAPI, Spring Boot, Rails, Laravel, Go). `hub/build.sh` rebuild. +- **Stub discovery** : `daedalus build` trouve désormais le stub natif (living next to the CLI, ou triple hôte) sans `DAEDALUS_STUB_PATH`. + ## Ce qui n'est PAS prioritaire - ❌ OCI/WASM (`--to oci|appimage|wasm`) — abandonné (dilution, cf Section 7) diff --git a/hub/build.sh b/hub/build.sh new file mode 100755 index 0000000..9298033 --- /dev/null +++ b/hub/build.sh @@ -0,0 +1,38 @@ +#!/bin/sh +# Rebuild all buildable hub apps from catalog.json into hub/dist/. +# +# hub/build.sh build all buildable apps +# hub/build.sh hello-web build one app by id +# +# Requires the daedalus CLI on PATH (or a stub next to the binary), e.g. +# after install.sh or `cargo build --release -p daedalus-cli -p daedalus-stub`. + +set -eu +cd "$(dirname "$0")/.." # catalog build commands are repo-root-relative + +want="${1:-}" +mkdir -p hub/dist + +python3 - "$want" <<'EOF' +import json, subprocess, sys + +with open("hub/catalog.json") as f: + catalog = json.load(f) + +want = sys.argv[1] if len(sys.argv) > 1 else None +for app in catalog["apps"]: + if want and app["id"] != want: + continue + if not app.get("buildable"): + if not want: + print("skip (recipe only):", app["id"]) + continue + build = app["build"] + print("building:", app["id"]) + subprocess.run(build, check=True) + print(" done:", build[build.index("-o") + 1]) +EOF + +if [ -n "$want" ]; then + echo "built hub/dist/$want.de" >&2 +fi \ No newline at end of file diff --git a/hub/catalog.json b/hub/catalog.json new file mode 100644 index 0000000..ba6015e --- /dev/null +++ b/hub/catalog.json @@ -0,0 +1,125 @@ +{ + "$schema": "./catalog.schema.json", + "$comment": "Community catalog of daedalus-packaged apps. One template per popular runtime (ROADMAP: adoption). Apps marked buildable:true have source in this repo and can be rebuilt with hub/build.sh. Recipe rows document the canonical source layout to replicate outside the repo.", + "apps": [ + { + "id": "hello-web", + "name": "hello-web", + "runtime": "python", + "template": "application", + "description": "Zero-dependency Python HTTP server. The fastest 'it's just the file' demo.", + "source": "examples/hello-web", + "build": ["daedalus", "build", "./examples/hello-web", "-o", "hub/dist/hello-web.de"], + "run": "./hello-web.de", + "port": 8080, + "buildable": true + }, + { + "id": "hello-node", + "name": "hello-node", + "runtime": "node", + "template": "application", + "description": "Minimal Node.js HTTP server (stdlib http).", + "source": "examples/hello-node", + "build": ["daedalus", "build", "./examples/hello-node", "-o", "hub/dist/hello-node.de"], + "run": "./hello-node.de", + "port": 8080, + "buildable": true + }, + { + "id": "bottle-web", + "name": "bottle-web", + "runtime": "python", + "template": "application", + "description": "Python Bottle app with a vendored site-packages directory — shows dependency embedding with zero pip/network.", + "source": "examples/bottle-web", + "build": ["daedalus", "build", "./examples/bottle-web", "-o", "hub/dist/bottle-web.de"], + "run": "./bottle-web.de", + "port": 8080, + "buildable": true + }, + { + "id": "clinic-agent", + "name": "clinic-agent", + "runtime": "python", + "template": "application", + "description": "Offline AI for rural clinics / agricultural cooperatives. Gemma via local Ollama with a pure-stdlib offline fallback. Flagship demo.", + "source": "examples/offline-health-agri", + "build": ["daedalus", "build", "./examples/offline-health-agri", "-o", "hub/dist/clinic-agent.de"], + "run": ["./clinic-agent.de", "diagnose"], + "buildable": true + }, + { + "id": "express-demo", + "name": "Express API", + "runtime": "node", + "template": "service", + "description": "Canonical Express app: package.json + index.js + node_modules. Build with SISR delta updates for free.", + "recipe": { + "layout": ["package.json (dependencies: express)", "index.js", "node_modules/ (installed)"], + "build": ["daedalus", "build", "./express-demo", "-o", "express-api.de", "--template", "service"] + }, + "buildable": false + }, + { + "id": "fastapi-demo", + "name": "FastAPI service", + "runtime": "python", + "template": "service", + "description": "Canonical FastAPI app: app/main.py + requirements.txt. --teleport-style dependency embed via pipdle-free vendoring or --embed-interpreter.", + "recipe": { + "layout": ["app/main.py (FastAPI)", "requirements.txt", "venv or site-packages vendored"], + "build": ["daedalus", "build", "./fastapi-demo", "-o", "fastapi.de", "--template", "service"] + }, + "buildable": false + }, + { + "id": "spring-boot-api", + "name": "Spring Boot API", + "runtime": "java", + "template": "service", + "description": "Spring Boot service. daedalus embeds a minimal jlink JRE (--embed-interpreter java) so the artifact runs without a system JDK.", + "recipe": { + "layout": ["mvnw or gradlew", "pom.xml / build.gradle (spring-boot-starter-web)", "src/main/java/**"], + "build": ["daedalus", "build", "./spring-api", "-o", "spring-api.de", "--embed-interpreter", "java", "--template", "service"] + }, + "buildable": false + }, + { + "id": "rails-api", + "name": "Rails API", + "runtime": "ruby", + "template": "service", + "description": "Ruby on Rails service. Bundler is auto-detected from the Gemfile.", + "recipe": { + "layout": ["Gemfile (rails, rails-api)", "app/**", "config.ru"], + "build": ["daedalus", "build", "./rails-api", "-o", "rails-api.de", "--template", "service"] + }, + "buildable": false + }, + { + "id": "laravel-api", + "name": "Laravel API", + "runtime": "php", + "template": "service", + "description": "PHP Laravel service — detected by composer.json.", + "recipe": { + "layout": ["composer.json (laravel/framework)", "artisan", "app/**"], + "build": ["daedalus", "build", "./laravel-api", "-o", "laravel-api.de", "--template", "service"] + }, + "buildable": false + }, + { + "id": "gin-server", + "name": "Go server (Gin)", + "runtime": "go", + "template": "service", + "description": "Go binary: daedalus runs `go build` via Cargo-style detection and stages the resulting static binary.", + "recipe": { + "layout": ["go.mod (gin-gonic/gin)", "main.go"], + "build": ["daedalus", "build", "./gin-server", "-o", "gin-server.de", "--template", "service"] + }, + "buildable": false + } + ] +} \ No newline at end of file diff --git a/hub/catalog.schema.json b/hub/catalog.schema.json new file mode 100644 index 0000000..fed237c --- /dev/null +++ b/hub/catalog.schema.json @@ -0,0 +1,33 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "required": ["apps"], + "properties": { + "apps": { + "type": "array", + "items": { + "type": "object", + "required": ["id", "name", "runtime", "template", "description"], + "properties": { + "id": { "type": "string", "pattern": "^[a-z0-9-]+$" }, + "name": { "type": "string" }, + "runtime": { "type": "string" }, + "template": { "enum": ["application", "service", "plugin"] }, + "description": { "type": "string" }, + "source": { "type": "string" }, + "build": { "type": "array", "items": { "type": "string" } }, + "recipe": { + "type": "object", + "properties": { + "layout": { "type": "array", "items": { "type": "string" } }, + "build": { "type": "array", "items": { "type": "string" } } + } + }, + "run": { "anyOf": [{ "type": "string" }, { "type": "array", "items": { "type": "string" } }] }, + "port": { "type": "integer" }, + "buildable": { "type": "boolean" } + } + } + } + } +} \ No newline at end of file diff --git a/install.ps1 b/install.ps1 new file mode 100644 index 0000000..baf1f5b --- /dev/null +++ b/install.ps1 @@ -0,0 +1,60 @@ +# daedalus installer for Windows PowerShell — from GitHub Releases, no admin. +# +# powershell -ExecutionPolicy Bypass -c "irm https://raw.githubusercontent.com/Encapsul/daedalus/main/install.ps1 | iex" +# +# Installs daedalus.exe + daedalus-stub.exe into $env:LOCALAPPDATA\daedalus\bin. +# Overridable: $env:DAEDALUS_VERSION, $env:DAEDALUS_INSTALL_DIR, +# $env:DAEDALUS_MIRROR (base URL, for mirror testing). + +$ErrorActionPreference = "Stop" + +function Get-LatestVersion { + $resp = Invoke-RestMethod -Uri "https://api.github.com/repos/Encapsul/daedalus/releases/latest" + return $resp.tag_name +} + +$mirror = if ($env:DAEDALUS_MIRROR) { $env:DAEDALUS_MIRROR } else { "https://github.com/Encapsul/daedalus/releases" } +$version = if ($env:DAEDALUS_VERSION) { $env:DAEDALUS_VERSION } else { Get-LatestVersion } +if (-not $version) { $version = "v0.7.0" } +$installdir = if ($env:DAEDALUS_INSTALL_DIR) { $env:DAEDALUS_INSTALL_DIR } else { Join-Path $env:LOCALAPPDATA "daedalus\bin" } + +$asset = "daedalus_$($version.TrimStart('v'))_windows_amd64.tar.gz" +$rel = "$mirror/download/$version" +Write-Host "daedalus installer: $asset from $version" + +$tmp = Join-Path $env:TEMP ("daedalus-" + [guid]::NewGuid().ToString("N")) +New-Item -ItemType Directory -Force -Path $tmp | Out-Null +try { + $archive = Join-Path $tmp $asset + Write-Host "daedalus installer: downloading $asset ..." + Invoke-WebRequest -Uri "$rel/$asset" -OutFile $archive -UseBasicParsing + + Write-Host "daedalus installer: verifying sha256 ..." + $manifest = (Invoke-WebRequest -Uri "$rel/checksums.txt" -UseBasicParsing).Content + $expected = ($manifest -split "`n" | ForEach-Object { + $parts = $_ -split "\s+" + if ($parts.Count -ge 2 -and $parts[1] -eq $asset) { $parts[0] } + } | Select-Object -First 1) + if (-not $expected) { throw "$asset missing from release checksums.txt" } + $actual = (Get-FileHash -Algorithm SHA256 $archive).Hash.ToLower() + if ($actual -ne $expected) { throw "sha256 mismatch: expected $expected got $actual" } + + tar -xf $archive -C $tmp + New-Item -ItemType Directory -Force -Path $installdir | Out-Null + Copy-Item -Force (Join-Path $tmp "daedalus_$($version.TrimStart('v'))_windows_amd64\daedalus.exe") $installdir + Copy-Item -Force (Join-Path $tmp "daedalus_$($version.TrimStart('v'))_windows_amd64\daedalus-stub.exe") $installdir + if (Test-Path (Join-Path $tmp "daedalus_$($version.TrimStart('v'))_windows_amd64\daedalus-crypto.exe")) { + Copy-Item -Force (Join-Path $tmp "daedalus_$($version.TrimStart('v'))_windows_amd64\daedalus-crypto.exe") $installdir -ErrorAction SilentlyContinue + } + + Write-Host "" + Write-Host "Installed daedalus $version -> $installdir" + Write-Host "Add it to your PATH:" + Write-Host " setx PATH \"$installdir;%PATH%\" # new terminals only" + Write-Host "Then open a new terminal and:" + Write-Host " daedalus build .\examples\offline-health-agri -o clinic-agent.de" + Write-Host " .\clinic-agent.de diagnose" +} +finally { + Remove-Item -Recurse -Force $tmp -ErrorAction SilentlyContinue +} \ No newline at end of file diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..193d24b --- /dev/null +++ b/install.sh @@ -0,0 +1,105 @@ +#!/bin/sh +# daedalus installer — single-command install from GitHub Releases. +# +# curl -fsSL https://raw.githubusercontent.com/Encapsul/daedalus/main/install.sh | sh +# +# Installs daedalus + daedalus-stub into ~/.local/bin (no sudo). Overridable: +# DAEDALUS_VERSION release tag (default: latest from GitHub API) +# DAEDALUS_INSTALL_DIR target directory (default: $HOME/.local/bin) +# DAEDALUS_MIRROR base URL (default: GitHub releases, for mirror testing) +# +# Security: the artifact is verified against the release checksums.txt +# (sha256) before installation. No code from the download runs until then. + +set -eu + +: "${DAEDALUS_INSTALL_DIR:=$HOME/.local/bin}" +: "${DAEDALUS_MIRROR:=https://github.com/Encapsul/daedalus/releases}" +VERSION="${DAEDALUS_VERSION:-}" + +HTTP_FETCH=curl +if ! command -v curl >/dev/null 2>&1; then + HTTP_FETCH="wget -qO-" +fi +fetch() { # fetch + if [ "$HTTP_FETCH" = curl ]; then + curl -fsSL "$1" + else + wget -qO- "$1" + fi +} + +os="$(uname -s | tr '[:upper:]' '[:lower:]')" +arch="$(uname -m)" +case "$os" in + linux) os=linux ;; + darwin) os=darwin ;; + *) echo "daedalus installer: unsupported OS: $os" >&2; exit 1 ;; +esac +case "$arch" in + x86_64|amd64) arch=amd64 ;; + aarch64|arm64) arch=arm64 ;; + *) echo "daedalus installer: unsupported arch: $arch" >&2; exit 1 ;; +esac + +release_url() { printf '%s' "$DAEDALUS_MIRROR"; } + +if [ -z "$VERSION" ]; then + # JSON from api.github.com avoids guessing the latest tag; the raw + # "tag_name" is trusted only to build the download path (sha256 check + # below is the actual trust anchor). + VERSION=$(fetch "https://api.github.com/repos/Encapsul/daedalus/releases/latest" \ + | sed -n 's/.*"tag_name": *"\(v[^"]*\)".*/\1/p' | head -1) + if [ -z "$VERSION" ]; then + VERSION=v0.7.0 + echo "daedalus installer: could not resolve latest release, falling back to $VERSION" >&2 + fi +fi + +asset="daedalus_${VERSION#v}_${os}_${arch}.tar.gz" +url="$(release_url)/download/$VERSION/$asset" +echo "daedalus installer: resolving $asset from $VERSION" +files_url="$(release_url)/download/$VERSION/checksums.txt" + +trap 'rm -rf "$tmpdir"' EXIT INT TERM +tmpdir="$(mktemp -d 2>/dev/null || mktemp -d /tmp/daedalus.XXXXXX)" +archive="$tmpdir/$asset" + +echo "daedalus installer: downloading $asset ..." +fetch "$url" > "$archive" + +echo "daedalus installer: verifying sha256 ..." +expected="$(fetch "$files_url" | awk -v a="$asset" '$2 == a { print $1 }' | head -1)" +if [ -z "$expected" ]; then + echo "daedalus installer: FATAL: $asset missing from release checksums.txt" >&2 + exit 1 +fi +if command -v sha256sum >/dev/null 2>&1; then + actual="$(sha256sum "$archive" | awk '{ print $1 }')" +else + actual="$(shasum -a 256 "$archive" | awk '{ print $1 }')" +fi +if [ "$actual" != "$expected" ]; then + echo "daedalus installer: FATAL: sha256 mismatch ($actual)" >&2 + exit 1 +fi + +mkdir -p "$DAEDALUS_INSTALL_DIR" +tar -xzf "$archive" -C "$tmpdir" +dir_suffix="daedalus_${VERSION#v}_${os}_${arch}" +for bin in daedalus daedalus-stub daedalus-crypto; do + if [ -f "$tmpdir/$dir_suffix/$bin" ]; then + cp "$tmpdir/$dir_suffix/$bin" "$DAEDALUS_INSTALL_DIR/$bin" + chmod +x "$DAEDALUS_INSTALL_DIR/$bin" + fi +done + +echo "" +echo "Installed daedalus ${VERSION} -> $DAEDALUS_INSTALL_DIR" +echo "" +echo "Add it to your PATH (already done if $DAEDALUS_INSTALL_DIR is on it):" +echo " export PATH=\"\$HOME/.local/bin:\$PATH\"" +echo "" +echo "Then package your first app (60 s, no install of anything else):" +echo " daedalus build ./examples/offline-health-agri -o clinic-agent.de" +echo " ./clinic-agent.de diagnose" \ No newline at end of file diff --git a/packaging/README.md b/packaging/README.md new file mode 100644 index 0000000..c1cf385 --- /dev/null +++ b/packaging/README.md @@ -0,0 +1,46 @@ +# Zero-friction installation + +One command, no compile, no sudo. A release archive is picked for the host +OS/arch, verified against the release `checksums.txt` (sha256), and unpacked +into a user-local `bin` directory with the stub beside the CLI. + +## Installers + +- **`install.sh`** (Linux/macOS, POSIX `sh`): + + ```bash + curl -fsSL https://raw.githubusercontent.com/Encapsul/daedalus/main/install.sh | sh + ``` + + `~/`-install into `$DAEDALUS_INSTALL_DIR` (default `~/.local/bin`) — no root. + Overrides: `DAEDALUS_VERSION`, `DAEDALUS_INSTALL_DIR`, `DAEDALUS_MIRROR`. + +- **`install.ps1`** (Windows PowerShell): + + ```powershell + powershell -ExecutionPolicy Bypass -c "irm https://raw.githubusercontent.com/Encapsul/daedalus/main/install.ps1 | iex" + ``` + + Installs into `%LOCALAPPDATA%\daedalus\bin` — no admin. Uses the built-in + `tar` on Windows 10+. + +- **Homebrew** — `homebrew/daedalus.rb`. This formula is for a `brew tap`; + fill the four `sha256` values (see the comment at the top of the formula) + and add the file to the tap repository. Not yet published. + +## Release assets + +`release.yml` publishes one archive per platform on every `v*` tag: + +``` +daedalus__linux_amd64.tar.gz daedalus + daedalus-stub [+ crypto] +daedalus__linux_arm64.tar.gz +daedalus__darwin_amd64.tar.gz +daedalus__darwin_arm64.tar.gz +daedalus__windows_amd64.tar.gz *.exe +checksums.txt +``` + +`cargo install` also works (`cargo install --path daedalus-cli`, or via +crates.io when published), but the stub must then be installed separately — +the release archives above are the supported one-command path. \ No newline at end of file diff --git a/packaging/homebrew/daedalus.rb b/packaging/homebrew/daedalus.rb new file mode 100644 index 0000000..2d1269e --- /dev/null +++ b/packaging/homebrew/daedalus.rb @@ -0,0 +1,56 @@ +# Homebrew formula for daedalus — installs the prebuilt release binary. +# +# To publish, fill the four sha256 values below from a GitHub release, then +# add this file to a Homebrew tap: +# +# gh release download v0.7.0 -p "daedalus_0.7.0_*_amd64.tar.gz" -p "daedalus_0.7.0_*_arm64.tar.gz" +# for f in daedalus_0.7.0_*_amd64.tar.gz daedalus_0.7.0_*_arm64.tar.gz; do +# case "$f" in +# *linux_amd64*) echo "linux amd64: $(sha256sum "$f" | cut -d' ' -f1)" ;; +# *linux_arm64*) echo "linux arm64: $(sha256sum "$f" | cut -d' ' -f1)" ;; +# *darwin_amd64*) echo "darwin amd64: $(shasum -a 256 "$f" | cut -d' ' -f1)" ;; +# *darwin_arm64*) echo "darwin arm64: $(shasum -a 256 "$f" | cut -d' ' -f1)" ;; +# esac +# done +# +# The stub ships with the CLI in the same archive, so `daedalus build` finds +# it next to the binary. + +class Daedalus < Formula + desc "Package any app into a single self-extracting binary" + homepage "https://github.com/Encapsul/daedalus" + url "https://github.com/Encapsul/daedalus/releases" + license "MIT" + version "0.7.0" + + on_macos do + if Hardware::CPU.arm? + url "https://github.com/Encapsul/daedalus/releases/download/v0.7.0/daedalus_0.7.0_darwin_arm64.tar.gz" + sha256 "REPLACE_WITH_DARWIN_ARM64_SHA256" + else + url "https://github.com/Encapsul/daedalus/releases/download/v0.7.0/daedalus_0.7.0_darwin_amd64.tar.gz" + sha256 "REPLACE_WITH_DARWIN_AMD64_SHA256" + end + end + + on_linux do + if Hardware::CPU.arm? + url "https://github.com/Encapsul/daedalus/releases/download/v0.7.0/daedalus_0.7.0_linux_arm64.tar.gz" + sha256 "REPLACE_WITH_LINUX_ARM64_SHA256" + else + url "https://github.com/Encapsul/daedalus/releases/download/v0.7.0/daedalus_0.7.0_linux_amd64.tar.gz" + sha256 "REPLACE_WITH_LINUX_AMD64_SHA256" + end + end + + def install + dir = Dir["daedalus_*"][0] # release tarballs carry a versioned folder + bin.install "#{dir}/daedalus", "daedalus" + bin.install "#{dir}/daedalus-stub", "daedalus-stub" + bin.install "#{dir}/daedalus-crypto", "daedalus-crypto" if File.exist?("#{dir}/daedalus-crypto") + end + + test do + system "#{bin}/daedalus", "--version" + end +end \ No newline at end of file From a4dc2b3cdce707fcfcaa60a70181e0466082bb72 Mon Sep 17 00:00:00 2001 From: Tednoob17 Date: Thu, 17 Sep 2026 15:06:59 +0100 Subject: [PATCH 08/11] feat: add Zig, Dart and Flutter runtimes Detect build.zig/build.zig.zon (Zig), pubspec.yaml without Flutter sdk (Dart), and sdk: flutter (Flutter); build each via the installed or auto-downloaded toolchain. - core: extend Runtime enum, detection and entrypoint resolution - cli: ensure_zig (ziglang manifest, tar.xz, 0.16 flat layout), ensure_dart (dart-archive, release.zip naming), build_*_binary steps, zig target-triple mapping, flutter bundle staging; full-path exec of auto-downloaded toolchains (PATH prepend was unreliable) - stub: direct-exec Zig/Dart/Flutter entrypoints - examples: hello-zig, hello-dart, Zig/Dart/Flutter guides - verified: fmt, clippy -D warnings (3 crates), test --workspace, e2e zig (Hello from Zig!) and dart (Hello from Dart!) --- .gitignore | 6 + ROADMAP.md | 5 +- daedalus-cli/src/commands/build/deps.rs | 388 ++++++++++++++++++++ daedalus-cli/src/commands/build/mod.rs | 2 +- daedalus-cli/src/commands/build/pipeline.rs | 349 +++++++++++++++++- daedalus-core/src/detect.rs | 221 ++++++++++- daedalus-stub/src/exec.rs | 14 +- docs/src/guides/dart.md | 79 ++++ docs/src/guides/flutter.md | 52 +++ docs/src/guides/zig.md | 91 +++++ examples/hello-dart/bin/main.dart | 5 + examples/hello-dart/pubspec.yaml | 8 + examples/hello-zig/build.zig | 16 + examples/hello-zig/src/main.zig | 5 + 14 files changed, 1227 insertions(+), 14 deletions(-) create mode 100644 docs/src/guides/dart.md create mode 100644 docs/src/guides/flutter.md create mode 100644 docs/src/guides/zig.md create mode 100644 examples/hello-dart/bin/main.dart create mode 100644 examples/hello-dart/pubspec.yaml create mode 100644 examples/hello-zig/build.zig create mode 100644 examples/hello-zig/src/main.zig diff --git a/.gitignore b/.gitignore index be38ff9..add5afc 100644 --- a/.gitignore +++ b/.gitignore @@ -59,6 +59,12 @@ environment_details.txt *.gguf examples/*/.deps/ examples/baguettotron/ +# Zig/Dart/Flutter example build products +examples/*/zig-out/ +examples/*/.zig-cache/ +examples/*/.dart_tool/ +examples/*/pubspec.lock +examples/*/build/ # gstack/opencode skills (managed externally) .opencode/ yc diff --git a/ROADMAP.md b/ROADMAP.md index 13d31f2..084c9dc 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -33,6 +33,9 @@ JRE embed (jlink) works on macOS. - Wasm (wasmtime embed) - Ollama (detection + `ollama serve` entrypoint) - Gemma (offline `.gguf` bundling via `--model`, `ollama run `, no cloud/GPU) +- Zig (build.zig/build.zig.zon detection, `zig build -Doptimize=ReleaseFast -Dtarget`, toolchain auto-download from ziglang manifest, AOT native) +- Dart (pubspec.yaml detection, `dart compile exe` AOT, toolchain auto-download from dart-archive; host-only — cross AOT refused with clear error) +- Flutter (pubspec with `sdk: flutter` detection, `flutter build --release`, desktop bundle; requires the Flutter SDK on PATH) - Perl (Mojolicious-specific detection: script/ + lib/ layout, `Mojo::` imports) - Electron (cross-OS/arch binary embed, OS-aware `is_cross`, `resources/` embedded beside binary) @@ -43,8 +46,6 @@ JRE embed (jlink) works on macOS. | Swift | iOS/macOS apps, trending via SwiftUI | | Kotlin | Android/JVM backend, growing | | Lua | Game mods, Neovim configs, OpenResty | -| Dart/Flutter | Mobile + web, growing | -| Zig | Trending language, single binary | | OCaml/Elm | Functional web, niche but real | | R | Data science, Shiny apps | | Elixir | Phoenix framework, real-time | diff --git a/daedalus-cli/src/commands/build/deps.rs b/daedalus-cli/src/commands/build/deps.rs index 20da9e3..0961ae5 100644 --- a/daedalus-cli/src/commands/build/deps.rs +++ b/daedalus-cli/src/commands/build/deps.rs @@ -53,6 +53,17 @@ pub(crate) fn interpreter_bin(bin_dir: &Path, name: &str, target: Option<&str>) }) } +/// `` on the build host, `.exe` when the host is Windows. Host-only +/// toolchain lookups (Zig, Dart) use this instead of `interpreter_bin`, which +/// keys off the *target*. +fn host_bin_name(name: &str) -> String { + if std::env::consts::OS == "windows" { + format!("{name}.exe") + } else { + name.to_string() + } +} + /// The `-` suffix used to namespace per-target tool installs, or /// `host` when building without a target. Shared by every `ensure_*` so each /// runtime resolves to the same install directory. @@ -1947,6 +1958,383 @@ fn extract_electron_zip( Ok(()) } +// --------------------------------------------------------------------------- +// Zig toolchain (host-only: `zig build -Dtarget=...` handles cross) +// --------------------------------------------------------------------------- + +/// Parse a `major.minor.patch` version string into a comparable tuple. +/// Non-semver keys (snapshots like `master`, `0.16.0-dev.123`) return `None`. +fn zig_semver(v: &str) -> Option<(u32, u32, u32)> { + let parts: Vec<&str> = v.split('.').collect(); + let major: u32 = parts.first()?.parse().ok()?; + let minor: u32 = parts.get(1)?.parse().ok()?; + let patch: u32 = parts.get(2)?.parse().ok()?; + Some((major, minor, patch)) +} + +/// Ensure `zig` is available on the build host. Returns the full path to the +/// `zig` binary. Prefers a system `zig`; otherwise downloads the latest stable +/// release into `~/.cache/daedalus/build-tools/zig-host/`. +pub(crate) fn ensure_zig(verbose: bool) -> Result { + let tools_dir = tools_dir_for("zig", None); + let zig_bin = zig_bin_path(&tools_dir); + + if zig_bin.exists() { + if verbose { + eprintln!(" using cached zig from {}", tools_dir.display()); + } + return Ok(zig_bin); + } + + if let Ok(p) = which::which("zig") { + if verbose { + eprintln!(" using system zig from {}", p.display()); + } + return Ok(p); + } + + if verbose { + eprintln!(" downloading zig to {}...", tools_dir.display()); + } + std::fs::create_dir_all(&tools_dir).context("failed to create build tools directory")?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions( + tools_dir.parent().unwrap_or(tools_dir.as_path()), + std::fs::Permissions::from_mode(0o700), + ) + .ok(); + } + + ensure_zig_download(tools_dir, verbose) +} + +#[allow(clippy::too_many_lines)] +fn ensure_zig_download(tools_dir: PathBuf, verbose: bool) -> Result { + let manifest: serde_json::Value = + reqwest::blocking::get("https://ziglang.org/download/index.json") + .context("failed to reach ziglang.org")? + .json() + .context("failed to parse Zig download manifest")?; + + let version = if let Ok(pinned) = std::env::var("DAEDALUS_ZIG_VERSION") { + if verbose { + eprintln!(" using pinned zig version {pinned} (DAEDALUS_ZIG_VERSION)"); + } + pinned + } else { + // The manifest keys include "master" (a snapshot) and older releases; + // pick the highest semantic `0.x.y` stable version. Dev/preview keys + // fail semver parsing and are skipped automatically. + manifest + .as_object() + .and_then(|m| { + m.keys() + .filter_map(|k| zig_semver(k).map(|sem| (sem, k))) + .max_by_key(|(sem, _)| *sem) + .map(|(_, k)| k.clone()) + }) + .ok_or_else(|| anyhow::anyhow!("no stable Zig version found in download manifest"))? + }; + + let entry = manifest + .get(&version) + .ok_or_else(|| anyhow::anyhow!("Zig version {version} not found in manifest"))?; + + // The manifest has per-arch objects keyed like "x86_64-linux", "aarch64-macos". + let host_key = match (std::env::consts::ARCH, std::env::consts::OS) { + ("x86_64", "linux") => "x86_64-linux", + ("aarch64", "linux") => "aarch64-linux", + ("x86_64", "macos") => "x86_64-macos", + ("aarch64", "macos") => "aarch64-macos", + ("x86_64", "windows") => "x86_64-windows", + (arch, os) => anyhow::bail!("unsupported host for Zig: {arch}-{os}"), + }; + + let arch_entry = entry + .get(host_key) + .ok_or_else(|| anyhow::anyhow!("Zig {version} has no prebuilt for {host_key}"))?; + + let url = arch_entry + .get("tarball") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Zig manifest missing tarball URL"))?; + + if verbose { + eprintln!(" downloading zig {version} ({host_key})..."); + } + + let response = reqwest::blocking::get(url) + .with_context(|| format!("failed to download Zig archive from {url}"))?; + + if cfg!(target_os = "windows") { + let mut bytes = Vec::new(); + let mut reader = std::io::BufReader::new(response); + std::io::Read::read_to_end(&mut reader, &mut bytes).context("failed to read Zig zip")?; + extract_zig_zip(std::io::Cursor::new(bytes), &tools_dir)?; + } else { + let reader = std::io::BufReader::new(response); + let decoder = xz2::read::XzDecoder::new(reader); + let mut archive = tar::Archive::new(decoder); + for entry in archive + .entries() + .context("failed to read Zig tarball entries")? + { + let mut entry = entry.context("failed to read tarball entry")?; + let path = entry.path()?.into_owned(); + let stripped: PathBuf = path.components().skip(1).collect(); + if stripped.components().count() == 0 { + continue; + } + let target = tools_dir.join(&stripped); + if let Some(parent) = target.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("failed to create directory {}", parent.display()))?; + } + entry + .unpack(&target) + .with_context(|| format!("failed to unpack {}", stripped.display()))?; + } + } + + let zig_bin = zig_bin_path(&tools_dir); + if !zig_bin.exists() { + anyhow::bail!( + "downloaded Zig archive missing zig binary — install manually: https://ziglang.org/download/" + ); + } + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&zig_bin, std::fs::Permissions::from_mode(0o755)).ok(); + } + + if verbose { + eprintln!(" zig {version} ready at {}", tools_dir.display()); + } + + Ok(zig_bin) +} + +/// The `zig` executable location: modern dists (0.16+) ship it at the archive +/// root, older ones under `bin/`. The binary stays put because Zig resolves its +/// `lib/` directory relative to the executable. +fn zig_bin_path(tools_dir: &Path) -> PathBuf { + let flat = tools_dir.join(host_bin_name("zig")); + let legacy = tools_dir.join("bin").join(host_bin_name("zig")); + if flat.exists() { + flat + } else { + legacy + } +} + +fn extract_zig_zip(reader: R, tools_dir: &Path) -> Result<()> { + let mut archive = zip::ZipArchive::new(reader).context("failed to read Zig zip")?; + for i in 0..archive.len() { + let mut entry = archive.by_index(i).context("failed to read zip entry")?; + let name = std::path::Path::new(entry.name()); + if name.is_absolute() + || name + .components() + .any(|c| matches!(c, std::path::Component::ParentDir)) + { + continue; + } + let stripped: PathBuf = name.components().skip(1).collect(); + if stripped.components().count() == 0 { + continue; + } + let target = tools_dir.join(&stripped); + if entry.is_dir() { + std::fs::create_dir_all(&target).context("failed to create directory")?; + continue; + } + if let Some(parent) = target.parent() { + std::fs::create_dir_all(parent).context("failed to create directory")?; + } + let mut file = std::fs::File::create(&target).context("failed to create file")?; + std::io::copy(&mut entry, &mut file).context("failed to unpack zip entry")?; + } + Ok(()) +} + +// --------------------------------------------------------------------------- +// Dart SDK (host-only: `dart compile exe` only targets the build host) +// --------------------------------------------------------------------------- + +/// Ensure the Dart SDK is available. Returns the full path to the `dart` +/// binary. Prefers a system `dart`; otherwise downloads the latest stable SDK +/// into `~/.cache/daedalus/build-tools/dart-host/`. +pub(crate) fn ensure_dart(verbose: bool) -> Result { + let tools_dir = tools_dir_for("dart", None); + // Extraction strips the archive's single top-level `dart-sdk/` dir, so the + // SDK root lands directly in `tools_dir` with `dart` under `bin/`. + let dart_bin = tools_dir.join("bin").join(host_bin_name("dart")); + + if dart_bin.exists() { + if verbose { + eprintln!(" using cached dart from {}", tools_dir.display()); + } + return Ok(dart_bin); + } + + if let Ok(p) = which::which("dart") { + if verbose { + eprintln!(" using system dart from {}", p.display()); + } + return Ok(p); + } + + if verbose { + eprintln!(" downloading dart to {}...", tools_dir.display()); + } + std::fs::create_dir_all(&tools_dir).context("failed to create build tools directory")?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions( + tools_dir.parent().unwrap_or(tools_dir.as_path()), + std::fs::Permissions::from_mode(0o700), + ) + .ok(); + } + + ensure_dart_download(tools_dir, verbose) +} + +#[allow(clippy::too_many_lines)] +fn ensure_dart_download(tools_dir: PathBuf, verbose: bool) -> Result { + let version = if let Ok(pinned) = std::env::var("DAEDALUS_DART_VERSION") { + if verbose { + eprintln!(" using pinned dart version {pinned} (DAEDALUS_DART_VERSION)"); + } + pinned + } else { + let ver_json: serde_json::Value = reqwest::blocking::get( + "https://storage.googleapis.com/dart-archive/channels/stable/release/latest/VERSION", + ) + .context("failed to reach Dart archive")? + .json() + .context("failed to parse Dart version manifest")?; + ver_json + .get("version") + .and_then(|v| v.as_str()) + .map(String::from) + .ok_or_else(|| anyhow::anyhow!("Dart VERSION manifest missing 'version'"))? + }; + + let (dart_os, dart_arch) = match (std::env::consts::OS, std::env::consts::ARCH) { + ("linux", "x86_64") => ("linux", "x64"), + ("linux", "aarch64") => ("linux", "arm64"), + ("macos", "x86_64") => ("macos", "x64"), + ("macos", "aarch64") => ("macos", "arm64"), + ("windows", "x86_64") => ("windows", "x64"), + ("windows", "aarch64") => ("windows", "arm64"), + (os, arch) => anyhow::bail!("unsupported host for Dart: {arch}-{os}"), + }; + + let sdk_stem = format!("dartsdk-{dart_os}-{dart_arch}"); + // Naming changed over time: current releases are `...-release.zip`, older + // ones were plain `dartsdk-{os}-{arch}.zip`. Probe both. + let candidates = [format!("{sdk_stem}-release.zip"), format!("{sdk_stem}.zip")]; + let mut bytes: Option> = None; + for sdk in &candidates { + let url = format!( + "https://storage.googleapis.com/dart-archive/channels/stable/release/{version}/sdk/{sdk}" + ); + let response = reqwest::blocking::get(&url); + if let Ok(resp) = response { + if resp.status().is_success() { + if verbose { + eprintln!(" downloading dart {version} ({sdk})..."); + } + let mut into = Vec::new(); + let mut reader = std::io::BufReader::new(resp); + std::io::Read::read_to_end(&mut reader, &mut into) + .context("failed to read Dart SDK zip")?; + bytes = Some(into); + break; + } + } + } + let bytes = bytes.ok_or_else(|| { + anyhow::anyhow!( + "Dart SDK {version} not found on dart-archive — install manually: https://dart.dev/get-dart" + ) + })?; + extract_dart_zip(std::io::Cursor::new(bytes), &tools_dir)?; + + let dart_bin = tools_dir.join("bin").join(host_bin_name("dart")); + if !dart_bin.exists() { + anyhow::bail!( + "downloaded Dart SDK missing dart binary — install manually: https://dart.dev/get-dart" + ); + } + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + // `dart compile exe` spawns sibling binaries (`dartaotruntime`, and + // `bin/utils/gen_snapshot`), so make the whole bin/ tree executable. + // Zip extraction can lose the exec bits, which execve reports as EACCES. + fn chmod_tree(dir: &std::path::Path) { + if let Ok(entries) = std::fs::read_dir(dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + chmod_tree(&path); + } else { + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)) + .ok(); + } + } + } + } + chmod_tree(&tools_dir.join("bin")); + } + + if verbose { + eprintln!(" dart {version} ready at {}", tools_dir.display()); + } + + Ok(tools_dir.join("bin").join(host_bin_name("dart"))) +} + +fn extract_dart_zip(reader: R, tools_dir: &Path) -> Result<()> { + let mut archive = zip::ZipArchive::new(reader).context("failed to read Dart zip")?; + for i in 0..archive.len() { + let mut entry = archive.by_index(i).context("failed to read zip entry")?; + let name = std::path::Path::new(entry.name()); + if name.is_absolute() + || name + .components() + .any(|c| matches!(c, std::path::Component::ParentDir)) + { + continue; + } + // The Dart SDK zip wraps all content under `dart-sdk/` — strip that prefix + // so the SDK root lands directly in `tools_dir`. + let stripped: PathBuf = name.components().skip(1).collect(); + if stripped.components().count() == 0 { + continue; + } + let target = tools_dir.join(&stripped); + if entry.is_dir() { + std::fs::create_dir_all(&target).context("failed to create directory")?; + continue; + } + if let Some(parent) = target.parent() { + std::fs::create_dir_all(parent).context("failed to create directory")?; + } + let mut file = std::fs::File::create(&target).context("failed to create file")?; + std::io::copy(&mut entry, &mut file).context("failed to unpack zip entry")?; + } + Ok(()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/daedalus-cli/src/commands/build/mod.rs b/daedalus-cli/src/commands/build/mod.rs index b1eb675..49d39fe 100644 --- a/daedalus-cli/src/commands/build/mod.rs +++ b/daedalus-cli/src/commands/build/mod.rs @@ -229,7 +229,7 @@ fn resolve_build_runtime( detect::Runtime::Gemma } else { detect::detect_runtime(app_dir).context( - "could not detect runtime — supported: python, node, deno, java, ruby, dotnet, go, php, perl, hugo, ollama, gemma, wasm, binary", + "could not detect runtime — supported: python, node, deno, flutter, dart, java, ruby, dotnet, go, zig, php, perl, hugo, ollama, gemma, wasm, binary", )? }; if verbose { diff --git a/daedalus-cli/src/commands/build/pipeline.rs b/daedalus-cli/src/commands/build/pipeline.rs index 613583c..8d00c5e 100644 --- a/daedalus-cli/src/commands/build/pipeline.rs +++ b/daedalus-cli/src/commands/build/pipeline.rs @@ -13,9 +13,9 @@ use std::path::{Path, PathBuf}; use super::args::{config_fingerprint, parse_target, BuildArgs, BuildPlan}; use super::deps::{ - check_php_platform_reqs, ensure_composer, ensure_deno, ensure_electron, ensure_go, ensure_hugo, - ensure_node, ensure_python, ensure_rust, ensure_wasmtime, has_workspace_protocol, - interpreter_bin, is_command_available, resolve_command, tools_dir_for, + check_php_platform_reqs, ensure_composer, ensure_dart, ensure_deno, ensure_electron, ensure_go, + ensure_hugo, ensure_node, ensure_python, ensure_rust, ensure_wasmtime, ensure_zig, + has_workspace_protocol, interpreter_bin, is_command_available, resolve_command, tools_dir_for, }; use super::payload::{copy_dir_recursive_with, create_squashfs_payload, include_points_to_env}; use super::sign::sign_macos_binary; @@ -201,10 +201,13 @@ pub(crate) fn build_single_target( // ── Build Go / Rust binaries, Maven/Gradle JARs, .NET binaries ──────── let go_binary_name = build_go_binary(plan, target.as_deref(), &rootfs)?; let rust_binary_name = build_rust_binary(plan, target.as_deref(), &rootfs)?; + let zig_binary_name = build_zig_binary(plan, target.as_deref(), &rootfs)?; let java_jar_name = build_java_binary(plan, &rootfs)?; let dotnet_binary_name = build_dotnet_binary(plan, target.as_deref(), &rootfs)?; let hugo_binary_name = build_hugo_binary(plan, target.as_deref(), &rootfs)?; let _deno_binary_name = build_deno_binary(plan, target.as_deref(), &rootfs)?; + let dart_binary_name = build_dart_binary(plan, target.as_deref(), &rootfs)?; + let flutter_binary_name = build_flutter_binary(plan, target.as_deref(), &rootfs)?; // ── Embed interpreter / N-API addons / RoadRunner into the rootfs ── // Skip when reusing rootfs — interpreter already present in payload. @@ -228,9 +231,12 @@ pub(crate) fn build_single_target( go_binary_name .as_deref() .or(rust_binary_name.as_deref()) + .or(zig_binary_name.as_deref()) .or(java_jar_name.as_deref()) .or(dotnet_binary_name.as_deref()) - .or(hugo_binary_name.as_deref()), + .or(hugo_binary_name.as_deref()) + .or(dart_binary_name.as_deref()) + .or(flutter_binary_name.as_deref()), args.wasi, args.component_model, ); @@ -977,6 +983,293 @@ fn build_rust_binary( Ok(Some(bin_name)) } +/// Build the Zig binary into `rootfs/app` and strip source files. Returns the +/// binary name, or `None` when the app is not Zig or `--no-install` is set. +fn build_zig_binary( + plan: &BuildPlan, + target: Option<&str>, + rootfs: &Path, +) -> Result> { + if plan.runtime != detect::Runtime::Zig || plan.no_install { + return Ok(None); + } + let app_dir = &plan.app_dir; + let verbose = plan.verbose; + + let zig_bin_path = ensure_zig(verbose)?; + + // `zig build` installs release artifacts under `zig-out/bin/`. The Zig + // standard template exposes `-Doptimize` and `-Dtarget` via + // `standardOptimizeOption`/`standardTargetOptions`. + let mut cmd = std::process::Command::new(&zig_bin_path); + cmd.arg("build").arg("-Doptimize=ReleaseFast"); + if let Some(target_str) = target { + let zig_target = zig_target_triple(target_str)?; + cmd.arg(format!("-Dtarget={zig_target}")); + if verbose { + eprintln!(" cross-compiling Zig for {zig_target}"); + } + } + cmd.current_dir(app_dir); + if verbose { + eprintln!(" zig build -Doptimize=ReleaseFast..."); + } + let status = cmd + .status() + .context("failed to run `zig build` — is Zig installed? (https://ziglang.org)")?; + if !status.success() { + anyhow::bail!("`zig build` failed with exit code {status}"); + } + + // Locate the install artifact: `zig-out/bin/` (or `.exe`). + let zig_out = app_dir.join("zig-out").join("bin"); + let entries = std::fs::read_dir(&zig_out) + .with_context(|| format!("no install artifacts in {}", zig_out.display()))?; + let mut files: Vec<_> = entries + .flatten() + .filter(|e| e.file_type().map(|t| t.is_file()).unwrap_or(false)) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .collect(); + files.sort(); + let Some(bin_name) = files.first() else { + anyhow::bail!("`zig build` succeeded but zig-out/bin is empty"); + }; + + let staged = rootfs.join("app").join(bin_name); + std::fs::copy(zig_out.join(bin_name), &staged) + .with_context(|| format!("failed to stage {bin_name}"))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&staged, std::fs::Permissions::from_mode(0o755))?; + } + + strip_compiled_sources(rootfs, bin_name); + if verbose { + eprintln!(" Zig binary built successfully"); + } + Ok(Some(bin_name.clone())) +} + +/// Build the Dart AOT executable into `rootfs/app` and strip source files. +/// Returns the binary name, or `None` when the app is not Dart or +/// `--no-install` is set. `dart compile exe` only targets the build host, so +/// cross-compilation is rejected up front. +fn build_dart_binary( + plan: &BuildPlan, + target: Option<&str>, + rootfs: &Path, +) -> Result> { + if plan.runtime != detect::Runtime::Dart || plan.no_install { + return Ok(None); + } + let app_dir = &plan.app_dir; + let verbose = plan.verbose; + + if let Some(t) = target { + let (arch, os) = parse_target(t); + let host_arch = std::env::consts::ARCH; + let host_os = if std::env::consts::OS == "macos" { + "darwin" + } else { + std::env::consts::OS + }; + if arch != host_arch || os != host_os { + anyhow::bail!( + "Dart AOT compilation targets the build host only — build on the target platform directly (no --target for Dart)" + ); + } + } + + let dart_bin_path = ensure_dart(verbose)?; + + let entry = detect::dart_entry_script(app_dir).ok_or_else(|| { + anyhow::anyhow!("no Dart entry script found (expected bin/main.dart or lib/main.dart)") + })?; + let bin_name = std::path::Path::new(&entry) + .file_stem() + .map(|s| s.to_string_lossy().into_owned()) + .ok_or_else(|| anyhow::anyhow!("invalid Dart entry script: {entry}"))?; + + // Resolve dependencies first — `dart compile exe` needs package_config. + let pub_status = std::process::Command::new(&dart_bin_path) + .arg("pub") + .arg("get") + .current_dir(app_dir) + .status() + .context("failed to run `dart pub get`")?; + if !pub_status.success() { + anyhow::bail!("`dart pub get` failed with exit code {pub_status}"); + } + + let staged = rootfs.join("app").join(&bin_name); + let status = std::process::Command::new(&dart_bin_path) + .args(["compile", "exe", &entry, "-o"]) + .arg(&staged) + .current_dir(app_dir) + .status() + .context("failed to run `dart compile exe`")?; + if !status.success() { + anyhow::bail!("`dart compile exe` failed with exit code {status}"); + } + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&staged, std::fs::Permissions::from_mode(0o755))?; + } + + strip_compiled_sources(rootfs, &bin_name); + if verbose { + eprintln!(" Dart binary built successfully"); + } + Ok(Some(bin_name)) +} + +/// Build a Flutter app for the target platform and stage the release bundle +/// into `rootfs/app/bundle/`. Returns the bundle-relative executable path, or +/// `None` when the app is not Flutter or `--no-install` is set. +/// +/// The Flutter SDK is not auto-downloaded (a ~1GB toolchain) — the system +/// `flutter` binary is required, mirroring how Java/.NET builds need their SDK. +fn build_flutter_binary( + plan: &BuildPlan, + target: Option<&str>, + rootfs: &Path, +) -> Result> { + if plan.runtime != detect::Runtime::Flutter || plan.no_install { + return Ok(None); + } + let app_dir = &plan.app_dir; + let verbose = plan.verbose; + + if !is_command_available("flutter") { + anyhow::bail!( + "`flutter` not found on PATH — Flutter builds need the Flutter SDK \ + (https://docs.flutter.dev/get-started/install). The SDK is too large to auto-download." + ); + } + + // The build platform follows `--target`, defaulting to the host OS. + let platform = match target { + Some(t) => match parse_target(t).1.as_str() { + "windows" => "windows", + "darwin" => "macos", + "linux" => "linux", + other => anyhow::bail!("unsupported cross-compile OS for Flutter: {other}"), + }, + None => match std::env::consts::OS { + "windows" => "windows", + "macos" => "macos", + _ => "linux", + }, + }; + + let pub_status = std::process::Command::new("flutter") + .args(["pub", "get"]) + .current_dir(app_dir) + .status() + .context("failed to run `flutter pub get`")?; + if !pub_status.success() { + anyhow::bail!("`flutter pub get` failed with exit code {pub_status}"); + } + + let build_status = std::process::Command::new("flutter") + .args(["build", platform, "--release"]) + .current_dir(app_dir) + .status() + .context("failed to run `flutter build`")?; + if !build_status.success() { + anyhow::bail!("`flutter build {platform}` failed with exit code {build_status}"); + } + + // Locate the release bundle for the platform. + let bundle_src = match platform { + "linux" => { + let arch = if std::env::consts::ARCH == "aarch64" { + "arm64" + } else { + "x64" + }; + app_dir + .join("build") + .join("linux") + .join(arch) + .join("release") + .join("bundle") + } + "windows" => app_dir + .join("build") + .join("windows") + .join("x64") + .join("runner") + .join("Release"), + "macos" => app_dir + .join("build") + .join("macos") + .join("Build") + .join("Products") + .join("Release"), + _ => unreachable!(), + }; + if !bundle_src.is_dir() { + anyhow::bail!( + "`flutter build {platform}` succeeded but release bundle not found at {}", + bundle_src.display() + ); + } + + let bundle_dst = rootfs.join("app").join("bundle"); + copy_dir_recursive_with(&bundle_src, &bundle_dst, false)?; + + let app_name = pubspec_display_name(app_dir); + if verbose { + eprintln!(" Flutter bundle staged to app/bundle ({app_name})"); + } + Ok(Some(format!("bundle/{app_name}"))) +} + +/// Human-readable app name for a Flutter app (pubspec `name:` or "app"). +fn pubspec_display_name(app_dir: &Path) -> String { + std::fs::read_to_string(app_dir.join("pubspec.yaml")) + .ok() + .and_then(|c| { + c.lines().find_map(|l| { + let rest = l.trim_start().strip_prefix("name:")?; + let name = rest.split('#').next()?.trim(); + (!name.is_empty()).then(|| name.to_string()) + }) + }) + .unwrap_or_else(|| "app".to_string()) +} + +/// `zig` target triple for `--target`, translated from daedalus arch/os names. +/// Zig accepts `x86_64-linux-gnu`, `x86_64-linux-musl`, `aarch64-macos`, etc. +fn zig_target_triple(target: &str) -> Result { + let (arch, os) = parse_target(target); + let arch = match arch.as_str() { + "x86_64" | "amd64" => "x86_64", + "aarch64" | "arm64" => "aarch64", + other => anyhow::bail!("unsupported cross-compile architecture for Zig: {other}"), + }; + let base = match os.as_str() { + "linux" => format!("{arch}-linux"), + "darwin" => format!("{arch}-macos"), + "windows" => format!("{arch}-windows"), + other => anyhow::bail!("unsupported cross-compile OS for Zig: {other}"), + }; + Ok(match base.as_str() { + "x86_64-linux" | "aarch64-linux" => { + if target.contains("musl") { + format!("{base}-musl") + } else { + format!("{base}-gnu") + } + } + _ => base, + }) +} + /// Build the Maven/Gradle JAR into `rootfs/app` and strip source files. /// Returns the JAR file name, or `None` when the app is not Java or /// `--no-install` is set (Phase 8 Step 3). @@ -2934,4 +3227,52 @@ mod tests { let rootfs = tempfile::tempdir().unwrap(); embed_electron_resources(std::path::Path::new("electron"), rootfs.path(), false); } + + #[test] + fn zig_target_triple_maps_os_and_abi() { + assert_eq!( + zig_target_triple("x86_64-unknown-linux-gnu").unwrap(), + "x86_64-linux-gnu" + ); + assert_eq!( + zig_target_triple("aarch64-unknown-linux-musl").unwrap(), + "aarch64-linux-musl" + ); + assert_eq!( + zig_target_triple("amd64-unknown-linux-gnu").unwrap(), + "x86_64-linux-gnu" + ); + assert_eq!( + zig_target_triple("aarch64-apple-darwin").unwrap(), + "aarch64-macos" + ); + assert_eq!( + zig_target_triple("x86_64-pc-windows-msvc").unwrap(), + "x86_64-windows" + ); + assert_eq!( + zig_target_triple("arm64-apple-darwin").unwrap(), + "aarch64-macos" + ); + assert!(zig_target_triple("s390x-unknown-linux-gnu").is_err()); + assert!(zig_target_triple("wasm32-unknown-unknown").is_err()); + } + + #[test] + fn pubspec_display_name_reads_name_field() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("pubspec.yaml"), + "name: daedalus_demo\nenvironment:\n sdk: \">=3.0.0 <4.0.0\"\n", + ) + .unwrap(); + assert_eq!( + pubspec_display_name(dir.path()), + "daedalus_demo".to_string() + ); + assert_eq!( + pubspec_display_name(dir.path().join("nope").as_path()), + "app".to_string() + ); + } } diff --git a/daedalus-core/src/detect.rs b/daedalus-core/src/detect.rs index 930074b..1be96ae 100644 --- a/daedalus-core/src/detect.rs +++ b/daedalus-core/src/detect.rs @@ -1,7 +1,7 @@ //! Runtime detection — identifies which runtime an app directory uses. //! //! Detection order matches the Python registry: -//! Python > Deno > Node > Electron > Java > Ruby > .NET > Rust > Go > PHP > Perl > Hugo > Wasm > Binary +//! Python > Deno > Node > Electron > Flutter > Dart > Java > Ruby > .NET > Rust > Zig > Go > PHP > Perl > Hugo > Wasm > Binary use std::io::Read; use std::path::Path; @@ -13,10 +13,16 @@ pub enum Runtime { Deno, Node, Electron, + /// A Flutter UI app (pubspec dependency on the `flutter` SDK). + Flutter, + /// A pure Dart CLI/server app (Dart SDK only, no Flutter engine). + Dart, Java, Ruby, Dotnet, Rust, + /// A Zig project (`build.zig`) compiled to a native binary via `zig build`. + Zig, Go, Php, Perl, @@ -40,10 +46,13 @@ impl Runtime { Self::Deno => "deno", Self::Node => "node", Self::Electron => "electron", + Self::Flutter => "flutter", + Self::Dart => "dart", Self::Java => "java", Self::Ruby => "ruby", Self::Dotnet => "dotnet", Self::Rust => "rust", + Self::Zig => "zig", Self::Go => "go", Self::Php => "php", Self::Perl => "perl", @@ -64,10 +73,13 @@ impl Runtime { "deno" => Some(Self::Deno), "node" => Some(Self::Node), "electron" => Some(Self::Electron), + "flutter" => Some(Self::Flutter), + "dart" => Some(Self::Dart), "java" => Some(Self::Java), "ruby" => Some(Self::Ruby), "dotnet" => Some(Self::Dotnet), "rust" => Some(Self::Rust), + "zig" => Some(Self::Zig), "go" => Some(Self::Go), "php" => Some(Self::Php), "perl" => Some(Self::Perl), @@ -104,6 +116,9 @@ pub fn detect_runtime(app_dir: &Path) -> Option { .is_some() } Runtime::Rust => app_dir.join("Cargo.toml").is_file(), + Runtime::Zig => app_dir.join("build.zig").is_file(), + Runtime::Dart => dart_entry_script(app_dir).is_some(), + Runtime::Flutter => app_dir.join("lib/main.dart").is_file(), Runtime::Go => { app_dir.join("main.go").is_file() || app_dir.join("go.mod").is_file() @@ -133,6 +148,11 @@ fn detect_runtime_candidates(dir: &Path) -> Vec<(Runtime, bool)> { if detect_electron(dir) { candidates.push((Runtime::Electron, true)); } + if detect_flutter(dir) { + candidates.push((Runtime::Flutter, true)); + } else if detect_dart(dir) { + candidates.push((Runtime::Dart, true)); + } if detect_gemma(dir) { candidates.push((Runtime::Gemma, true)); } else if detect_ollama(dir) { @@ -153,6 +173,9 @@ fn detect_runtime_candidates(dir: &Path) -> Vec<(Runtime, bool)> { if detect_rust(dir) { candidates.push((Runtime::Rust, true)); } + if detect_zig(dir) { + candidates.push((Runtime::Zig, true)); + } if detect_go(dir) { candidates.push((Runtime::Go, true)); } @@ -312,6 +335,76 @@ fn detect_rust(dir: &Path) -> bool { dir.join("Cargo.toml").is_file() } +/// `detect_zig` - detect zig. +/// `@dir`: directory path +/// +/// Description: +/// +/// Return: true or false +fn detect_zig(dir: &Path) -> bool { + dir.join("build.zig").is_file() || dir.join("build.zig.zon").is_file() +} + +/// `detect_dart` - detect dart. +/// `@dir`: directory path +/// +/// Description: +/// +/// Return: true or false +/// +/// A Dart project has a `pubspec.yaml` without a `flutter` SDK dependency +/// (those are Flutter projects) and at least one reachable entry script. +fn detect_dart(dir: &Path) -> bool { + if dir.join("pubspec.yaml").is_file() && !detect_flutter(dir) { + return dart_entry_script(dir).is_some(); + } + false +} + +/// `detect_flutter` - detect flutter. +/// `@dir`: directory path +/// +/// Description: +/// +/// Return: true or false +fn detect_flutter(dir: &Path) -> bool { + // The Flutter tool generators write `flutter: { sdk: flutter }` under + // `dependencies:` — the "sdk: flutter" line is the unambiguous marker + // (a bare `flutter:` also appears as a top-level asset section). + std::fs::read_to_string(dir.join("pubspec.yaml")) + .is_ok_and(|c| c.lines().any(|l| l.contains("sdk: flutter"))) +} + +/// The `name:` field of a pubspec.yaml, mirroring Dart's package name rules. +fn pubspec_name(dir: &Path) -> Option { + let contents = std::fs::read_to_string(dir.join("pubspec.yaml")).ok()?; + contents.lines().find_map(|line| { + let rest = line.trim_start().strip_prefix("name:")?; + let name = rest.split('#').next()?.trim(); + (name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') && !name.is_empty()) + .then(|| name.to_string()) + }) +} + +/// The Dart entry script relative to the project root: `bin/main.dart` (Dart +/// CLI convention), `lib/main.dart`, or the first `.dart` file in `bin/`. +pub fn dart_entry_script(dir: &Path) -> Option { + if dir.join("bin/main.dart").is_file() { + return Some("bin/main.dart".into()); + } + if dir.join("lib/main.dart").is_file() { + return Some("lib/main.dart".into()); + } + std::fs::read_dir(dir.join("bin")) + .ok()? + .flatten() + .find(|e| { + let p = e.path(); + p.is_file() && p.extension().is_some_and(|e| e == "dart") + }) + .map(|e| format!("bin/{}", e.file_name().to_string_lossy().into_owned())) +} + /// `detect_go` - detect go. /// `@dir`: directory path /// @@ -881,10 +974,23 @@ pub fn resolve_entrypoint(app_dir: &Path, runtime: Runtime) -> Option { + Runtime::Go | Runtime::Rust | Runtime::Zig | Runtime::Binary => { let bin = find_native_binary(app_dir)?; Some(vec![format!("/app/{}", bin)]) } + Runtime::Dart => { + // AOT-compiled by the CLI to `rootfs/app/