diff --git a/Cargo.lock b/Cargo.lock index 6533498..2106c34 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "ahash" version = "0.8.12" @@ -379,6 +385,15 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "crossbeam-deque" version = "0.8.6" @@ -578,6 +593,16 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "fnv" version = "1.0.7" @@ -1362,6 +1387,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.1.1" @@ -1403,6 +1438,7 @@ dependencies = [ "clap", "crossterm", "dirs", + "flate2", "futures-util", "globset", "http-body-util", @@ -2142,6 +2178,12 @@ dependencies = [ "libc", ] +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + [[package]] name = "slab" version = "0.4.12" diff --git a/Cargo.toml b/Cargo.toml index 4faff02..99879a6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -77,6 +77,8 @@ ignore = "0.4" # Docker build context (tar archives) tar = "0.4" +# Gunzip the GitHub source tarball fetched by `n8 build` on installs (no git needed) +flate2 = "1" bytes = "1" # TUI progress display diff --git a/src/lib.rs b/src/lib.rs index e5c8cc7..e8c6499 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -38,6 +38,18 @@ pub mod transcript; +/// Heuristic: is this the nemesis8 agent-image Dockerfile, and not some +/// unrelated project's Dockerfile that merely shares the filename? The nemesis8 +/// Dockerfile builds `nemesis8-entry` FROM `nemesis8-base`; no foreign project +/// carries those tokens. Guards `project_dir_fn` against tagging a stranger's +/// image as `nemesis8:latest` just because the cwd happens to have a Dockerfile. +fn is_nemesis8_dockerfile(path: &std::path::Path) -> bool { + match std::fs::read_to_string(path) { + Ok(contents) => contents.contains("nemesis8-entry") || contents.contains("nemesis8-base"), + Err(_) => false, + } +} + /// Resolve the nemesis8 project directory (Dockerfile, MCP/, etc.) /// Downloads build files from GitHub on first run if not found locally. pub fn project_dir_fn() -> std::path::PathBuf { @@ -46,10 +58,26 @@ pub fn project_dir_fn() -> std::path::PathBuf { return std::path::PathBuf::from(dir); } - // 2. Current working directory (if run from repository root) + // 2. Current working directory — but ONLY if its Dockerfile is actually + // nemesis8's. A bare "cwd has a Dockerfile" check is a footgun: running + // `n8 build` (or triggering an auto-build) from any OTHER repo that happens + // to ship a Dockerfile (e.g. sibling project `skiff`) would build THAT + // project and stamp the result `nemesis8:latest` (DEFAULT_IMAGE is fixed), + // silently clobbering the real agent image. Verify the Dockerfile carries a + // nemesis8 build marker before trusting cwd; warn (don't silently build the + // wrong thing) when a foreign Dockerfile is present. if let Ok(cwd) = std::env::current_dir() { - if cwd.join("Dockerfile").is_file() { - return cwd; + let df = cwd.join("Dockerfile"); + if df.is_file() { + if is_nemesis8_dockerfile(&df) { + return cwd; + } + eprintln!( + "[nemesis8] ignoring Dockerfile in {} — not the nemesis8 build \ + (no nemesis8-base/nemesis8-entry marker). Run from the nemesis8 \ + repo or set NEMESIS8_PROJECT_DIR to avoid clobbering nemesis8:latest.", + cwd.display() + ); } } @@ -59,52 +87,109 @@ pub fn project_dir_fn() -> std::path::PathBuf { return compile_time; } - // 3. ~/.nemesis8/project — downloaded build files + // 4. ~/.nemesis8/project — the build context fetched for INSTALLED users + // (binary only, no repo/Dockerfile). We pin the fetch to THIS binary's + // version so `n8 build` always produces a container that matches the binary + // that built it — never a floating `main` that has drifted ahead. + let version = env!("CARGO_PKG_VERSION"); let home_dir = dirs::home_dir() .unwrap_or_else(|| std::path::PathBuf::from(".")) .join(".nemesis8") .join("project"); + let stamp = home_dir.join(".nemesis8-version"); - if home_dir.join("Dockerfile").is_file() { - // Always pull latest so the image stays in sync with the release - eprintln!("[nemesis8] Updating project files from GitHub..."); - let _ = std::process::Command::new("git") - .args(["-C", &home_dir.display().to_string(), "pull", "--ff-only", "--quiet"]) - .status(); + // Reuse a cached context only if it's complete AND matches our version. + let cached_version = std::fs::read_to_string(&stamp).ok(); + let cache_valid = home_dir.join("Dockerfile").is_file() + && cached_version.as_deref().map(str::trim) == Some(version); + if cache_valid { return home_dir; } - // 4. Download build files from GitHub - eprintln!("[nemesis8] Downloading build files on first run..."); - if let Err(e) = download_build_files(&home_dir) { - eprintln!("[nemesis8] warning: failed to download build files: {e}"); - eprintln!("[nemesis8] Set NEMESIS8_PROJECT_DIR to the nemesis8 source directory."); + // Missing or version-mismatched → fetch the tag matching this binary. + eprintln!("[nemesis8] fetching v{version} build context (installed binary, no local repo)..."); + if let Err(e) = download_build_files(&home_dir, version) { + eprintln!("[nemesis8] warning: failed to fetch build context: {e}"); + eprintln!("[nemesis8] Set NEMESIS8_PROJECT_DIR to a nemesis8 source checkout to build offline."); return compile_time; } + let _ = std::fs::write(&stamp, version); home_dir } -/// Download the project by shallow-cloning the repo -fn download_build_files(dest: &std::path::Path) -> Result<(), Box> { - // Remove dest if it exists (clean slate) +/// Fetch the nemesis8 build context for tag `v{version}` into `dest`. +/// +/// Primary path is a plain HTTPS download of the GitHub source tarball + local +/// unpack — NO `git` required, since installed users usually don't have it. The +/// archive's top-level `nemesis8-{version}/` directory is stripped so `dest` +/// holds the repo contents directly. Falls back to a shallow `git clone` of the +/// tag only if the HTTPS path fails and `git` happens to be available. +fn download_build_files( + dest: &std::path::Path, + version: &str, +) -> Result<(), Box> { + // Clean slate — a half-extracted or stale context must not linger. if dest.exists() { std::fs::remove_dir_all(dest)?; } + std::fs::create_dir_all(dest)?; - let status = std::process::Command::new("git") - .args([ - "clone", "--depth", "1", - "https://github.com/DeepBlueDynamics/nemesis8.git", - &dest.display().to_string(), - ]) - .status()?; - - if !status.success() { - return Err("git clone failed".into()); + let tag = format!("v{version}"); + match fetch_tarball(dest, &tag) { + Ok(()) => { + eprintln!("[nemesis8] build context v{version} unpacked to {}", dest.display()); + Ok(()) + } + Err(e) => { + eprintln!("[nemesis8] tarball fetch failed ({e}); trying git clone of {tag}..."); + let status = std::process::Command::new("git") + .args([ + "clone", "--depth", "1", "--branch", &tag, + "https://github.com/DeepBlueDynamics/nemesis8.git", + &dest.display().to_string(), + ]) + .status() + .map_err(|ge| format!("git not available ({ge}) after tarball error: {e}"))?; + if !status.success() { + return Err(format!("git clone of {tag} failed (tarball error: {e})").into()); + } + eprintln!("[nemesis8] build context v{version} cloned to {}", dest.display()); + Ok(()) + } } +} + +/// Download `archive/refs/tags/{tag}.tar.gz` over HTTPS and extract into `dest`, +/// stripping the single `nemesis8-*/` top-level directory GitHub wraps it in. +fn fetch_tarball(dest: &std::path::Path, tag: &str) -> Result<(), Box> { + let url = format!( + "https://github.com/DeepBlueDynamics/nemesis8/archive/refs/tags/{tag}.tar.gz" + ); + let resp = reqwest::blocking::Client::builder() + .user_agent(concat!("nemesis8/", env!("CARGO_PKG_VERSION"))) + .build()? + .get(&url) + .send()? + .error_for_status()?; + let bytes = resp.bytes()?; - eprintln!("[nemesis8] Project downloaded to {}", dest.display()); + let gz = flate2::read::GzDecoder::new(std::io::Cursor::new(bytes)); + let mut archive = tar::Archive::new(gz); + for entry in archive.entries()? { + let mut entry = entry?; + let path = entry.path()?.into_owned(); + // Strip the leading `nemesis8-/` component so files land at dest root. + let stripped: std::path::PathBuf = path.components().skip(1).collect(); + if stripped.as_os_str().is_empty() { + continue; + } + let out = dest.join(stripped); + if let Some(parent) = out.parent() { + std::fs::create_dir_all(parent)?; + } + entry.unpack(&out)?; + } Ok(()) }