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 c90ae91..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,14 +53,30 @@ jobs: - name: Run tests run: cargo test --workspace - - name: Smoke test (x86_64) + - name: Smoke test (native) run: | - /tmp/daedalus-stub-target/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: | 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/AGENTS.md b/AGENTS.md index 637efc5..9d01c05 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -104,6 +104,26 @@ Entrypoint resolution in `detect.rs:resolve_entrypoint()`: - Changing encryption/signing logic in `encrypt.rs`. - Adding new `unsafe` blocks or FFI bindings. +## Agent workflow (growth edge) + +Rules distilled from the Paxel builder-profile review. They bind BOTH sides: the +operator and the agent. + +**Plan before execution:** +- Before starting a multi-step task, state a short plan up front: goal, likely + failure points, the exact commands to run, and what "done" means (a pass on the + verification loop, an artifact that runs, etc.). Do not start editing before + the plan is stated. +- When redirecting mid-task, first report the current state: what is done, what + is committed/pushed, and the cheapest validation step. No new large direction + change before validating where the work actually stands. + +**Close the loop after environment fixes:** +- After fixing PATH / toolchain / installed-dependency issues, ALWAYS re-verify + final success and capture the proof in the session: the successful command + output (or exit code) that shows the fix worked. A "it should work now" is not + done — a green result is done. + ## Testing - Unit tests: `#[cfg(test)] mod tests` in each module. diff --git a/CLAUDE.md b/CLAUDE.md index a80b4b8..0fcc1b8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -39,6 +39,23 @@ Before finishing any code change, run: 5. `cargo test --workspace` 6. `cargo build --release && ./target/release/daedalus build examples/hello-web -o /tmp/test.de && ./target/release/daedalus inspect /tmp/test.de` +## Working with agents (growth edge) + +Rules distilled from the Paxel builder-profile review. They bind both the +operator and the agent. + +- **Plan before execution.** Before a multi-step task, state a short plan: goal, + likely failure points, the exact commands to run, and what "done" means (a + green verification loop, an artifact that runs). Do not start editing before + the plan is stated. +- **Redirects need current state.** When steering an agent off its path, first + get: what is done, what is committed/pushed, and the cheapest validation step. + No new large direction before validating where the work actually stands. +- **Close the loop after environment fixes.** After PATH / toolchain / + installed-dependency fixes, ALWAYS re-verify final success and keep the proof + (command output or exit code) in the session. "It should work now" is not done + — a green result is done. + ## Security Rules - No `unsafe` in `daedalus-core/` (only `stub/src/main.rs`) diff --git a/Cargo.lock b/Cargo.lock index 696d97c..3175e48 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2923,11 +2923,10 @@ 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 = [ - "log", "once_cell", "ring", "rustls-pki-types", @@ -3350,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", @@ -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/ROADMAP.md b/ROADMAP.md index 4b1a7dd..23f8b8f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,19 @@ # 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; 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 ### Complete / production-ready @@ -11,7 +25,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 +33,12 @@ - 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~~ | +- 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) +- Lua (main.lua/init.lua detection, host `lua` interpreter embed + shared-lib deps, direct script exec) ### Missing — planned @@ -34,9 +46,6 @@ |---------|-----------| | 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 | @@ -73,7 +82,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 +90,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 +103,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 @@ -103,11 +113,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/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/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/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-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 0417d60..6104bd2 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, ); @@ -306,6 +312,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, @@ -976,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). @@ -1660,6 +1954,7 @@ fn resolve_embed_interpreter(args: &BuildArgs, runtime_name: &str) -> Option Some("hugo".to_string()), "electron" => Some("electron".to_string()), "wasm" => Some("wasmtime".to_string()), + "lua" => Some("lua".to_string()), _ => None, } } @@ -2220,6 +2515,7 @@ fn resolve_bun_interpreter( "ruby" => EmbeddedInterpreter::Ruby, "php" => EmbeddedInterpreter::Php, "perl" => EmbeddedInterpreter::Perl, + "lua" => EmbeddedInterpreter::Lua, "java" => EmbeddedInterpreter::Java, "go" => EmbeddedInterpreter::Go, "wasm" => EmbeddedInterpreter::Wasm, @@ -2233,6 +2529,7 @@ fn resolve_bun_interpreter( "php" => (Some(EmbeddedInterpreter::Php), None), "ruby" => (Some(EmbeddedInterpreter::Ruby), None), "deno" => (Some(EmbeddedInterpreter::Deno), None), + "lua" => (Some(EmbeddedInterpreter::Lua), None), _ => (None, None), } } @@ -2933,4 +3230,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-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) + ), + } + } } 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/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/daedalus-core/src/detect.rs b/daedalus-core/src/detect.rs index cef0083..970855c 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 > Lua > Hugo > Wasm > Binary use std::io::Read; use std::path::Path; @@ -13,13 +13,20 @@ 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, + Lua, Hugo, Ollama, /// A bundled Google Gemma model served through a local Ollama runtime. @@ -40,13 +47,17 @@ 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", + Self::Lua => "lua", Self::Hugo => "hugo", Self::Ollama => "ollama", Self::Gemma => "gemma", @@ -64,13 +75,17 @@ 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), + "lua" => Some(Self::Lua), "hugo" => Some(Self::Hugo), "ollama" => Some(Self::Ollama), "gemma" => Some(Self::Gemma), @@ -104,6 +119,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 +151,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 +176,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)); } @@ -162,6 +188,9 @@ fn detect_runtime_candidates(dir: &Path) -> Vec<(Runtime, bool)> { if detect_perl(dir) { candidates.push((Runtime::Perl, true)); } + if detect_lua(dir) { + candidates.push((Runtime::Lua, true)); + } if detect_hugo(dir) { candidates.push((Runtime::Hugo, true)); } @@ -312,6 +341,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 /// @@ -564,6 +663,11 @@ fn mojolicious_script(dir: &Path) -> Option { None } +/// `detect_lua` - detect lua +fn detect_lua(dir: &Path) -> bool { + dir.join("main.lua").is_file() || dir.join("init.lua").is_file() +} + /// `detect_hugo` - detect hugo. /// `@dir`: directory path /// @@ -726,12 +830,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) } @@ -867,10 +985,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/