From 78893d0209aa55f7bff0cef36bbb69c1bd783b69 Mon Sep 17 00:00:00 2001 From: gl0w Date: Wed, 9 Sep 2026 01:49:16 +0100 Subject: [PATCH] install: handoff to project installer, drop generic copy (#49 97eeb419-d05e-49b2-b23a-e1ccef967219) --- .forge/wiki/docs/AGENTS.md | 7 +- crates/cli/src/cmd/release.rs | 191 ++++++++++++++++++++++------------ crates/cli/tests/install.rs | 132 +++++++++++++---------- spec/schema.md | 15 +-- 4 files changed, 209 insertions(+), 136 deletions(-) diff --git a/.forge/wiki/docs/AGENTS.md b/.forge/wiki/docs/AGENTS.md index dd93130..21e33d9 100644 --- a/.forge/wiki/docs/AGENTS.md +++ b/.forge/wiki/docs/AGENTS.md @@ -19,12 +19,7 @@ dogfoods: building f0rge is the first f0rge project (from PLAN M3 on). - moving tip: `f0rge release install main` (builds current checkout) - preview only: append `--dry-run` (prints each step, changes nothing) -A lone file under `.forge/releases/build//` copies to -`$CARGO_HOME/bin/` (any project, any language); otherwise -the resolved tree compiles via `cargo install --locked` (bin name from -the manifest). f0rge is the example: it drops -`.forge/releases/build//f0rge`, and a fresh machine starts with -`cargo install --locked --path .` from the clone. +f0rge release install resolves the release and runs the project's installer (or cargo for cargo projects); placement is the project script's job. Convention: repo-root `install.sh` runs as `./install.sh `; cargo projects without it use `cargo install --locked`; otherwise it prints `no installer for this project (install.sh or Cargo.toml)`. f0rge is the example: it has no `install.sh`, so a fresh machine starts with `cargo install --locked --path .` from the clone. ## Reading order diff --git a/crates/cli/src/cmd/release.rs b/crates/cli/src/cmd/release.rs index f05aadc..0f3383b 100644 --- a/crates/cli/src/cmd/release.rs +++ b/crates/cli/src/cmd/release.rs @@ -592,22 +592,50 @@ fn cargo_install_from(src: &Path) -> Result<(), CliError> { } } -/// Lone prebuilt file under releases/build// (any name, any -/// language); Some(path) only when exactly one file sits there (#48). -fn single_artifact(dir: &Path) -> Option { - let rd = std::fs::read_dir(dir).ok()?; - let mut files = Vec::new(); - for e in rd.flatten() { - let ft = match e.file_type() { - Ok(t) => t, - Err(_) => continue, - }; - if ft.is_symlink() || !ft.is_file() { - continue; - } - files.push(e.path()); +/// Installer convention (#49): repo-root `install.sh` run argv-only +/// as `./install.sh ` owns placement for its ecosystem. +fn ref_has_file(repo: &Path, gitref: &str, name: &str) -> bool { + Command::new("git") + .arg("-C") + .arg(repo) + .args(["cat-file", "-e", &format!("{gitref}:{name}")]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false) +} + +fn resolved_has(repo: &Path, at: &Option, dir: &Path, name: &str) -> bool { + match at { + Some(r) => ref_has_file(repo, r, name), + None => dir.join(name).is_file(), } - if files.len() == 1 { files.into_iter().next() } else { None } +} + +/// Run the project installer argv-only, no shell (SEC-04, local posture). +fn run_install_sh(dir: &Path, ver: &str) -> Result<(), CliError> { + let st = Command::new(dir.join("install.sh")).arg(ver).current_dir(dir).status(); + match st { + Ok(s) if s.success() => Ok(()), + _ => Err(CliError::Runtime("install failed".to_string())), + } +} + +fn emit_script(fs: &args::FlagSet, ver: &str) { + if fs.is("json") { + output::emit_json( + &InstallJson { + path: "./install.sh".to_string(), + sha256: String::new(), + ref_: ver.to_string(), + method: "install.sh".to_string(), + }, + fs.get("jq"), + ); + return; + } + output::out(&format!("ran ./install.sh {ver}\n")); } /// Bin name from a Cargo manifest: first [[bin]].name, else @@ -667,9 +695,10 @@ fn safe_bin(dir: &Path) -> String { } } -/// Pinned install (#48): a lone build artifact copies to -/// $CARGO_HOME/bin/; else cargo install from the resolved -/// tree (record sha > git tag > current checkout). Argv-only throughout. +/// Pinned install (#49): resolve record sha > git tag > current +/// checkout, stage releases/build//, then hand off: repo-root +/// install.sh argv-only as ./install.sh , else cargo install +/// for cargo projects, else one-line no-installer error. Argv-only. fn install_ver( fs: &args::FlagSet, fg: &Forge, @@ -677,32 +706,6 @@ fn install_ver( ver: &str, dry: bool, ) -> Result<(), CliError> { - let build_dir = fg.root().join("releases").join("build").join(ver); - if let Some(art) = single_artifact(&build_dir) { - let name = art - .file_name() - .map(|s| s.to_string_lossy().into_owned()) - .unwrap_or_else(|| "f0rge".to_string()); - let dest = cargo_dest_for(&name)?; - if dry { - output::out(&format!("would copy {} to {}\n", art.display(), dest.display())); - return Ok(()); - } - if let Some(p) = dest.parent() { - let _ = std::fs::create_dir_all(p); - } - std::fs::copy(&art, &dest) - .map_err(|_| CliError::Runtime("install failed".to_string()))?; - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let _ = - std::fs::set_permissions(&dest, std::fs::Permissions::from_mode(0o755)); - } - let sha = sha256_of(&dest).unwrap_or_default(); - emit_installed(fs, &dest, &sha, ver, "copy"); - return Ok(()); - } let sha = record_sha(fg, ver); let tag_ok = tag_exists(repo, ver); let sha_ok = sha.as_ref().is_some_and(|s| tag_exists(repo, s)); @@ -714,42 +717,80 @@ fn install_ver( } else { (None, format!("{ver} (current checkout)")) }; - let dest = cargo_dest_for(&safe_bin(repo))?; - if dry { - output::out(&format!( - "would run: cargo install --locked --path to {}\n", - dest.display() - )); - return Ok(()); - } - match at { - Some(r) => match try_worktree(repo, &r, ver) { - Some(wt) => { - let res = cargo_install_from(&wt); - let dest = cargo_dest_for(&safe_bin(&wt)).unwrap_or(dest); - cleanup_worktree(repo, &wt); - res?; - let sha = sha256_of(&dest).unwrap_or_default(); - emit_installed(fs, &dest, &sha, ver, "compile"); + let has_sh = resolved_has(repo, &at, repo, "install.sh"); + let has_cargo = resolved_has(repo, &at, repo, "Cargo.toml"); + if has_sh { + if dry { + output::out(&format!("would run: ./install.sh {ver} (checkout of {label})\n")); + return Ok(()); + } + let build_dir = fg.root().join("releases").join("build").join(ver); + let _ = std::fs::create_dir_all(&build_dir); + match at { + Some(r) => match try_worktree(repo, &r, ver) { + Some(wt) => { + let res = run_install_sh(&wt, ver); + cleanup_worktree(repo, &wt); + res?; + emit_script(fs, ver); + Ok(()) + } + None => { + run_install_sh(repo, ver)?; + emit_script(fs, ver); + Ok(()) + } + }, + None => { + run_install_sh(repo, ver)?; + emit_script(fs, ver); Ok(()) } + } + } else if has_cargo { + let dest = cargo_dest_for(&safe_bin(repo))?; + if dry { + output::out(&format!( + "would run: cargo install --locked --path to {}\n", + dest.display() + )); + return Ok(()); + } + let build_dir = fg.root().join("releases").join("build").join(ver); + let _ = std::fs::create_dir_all(&build_dir); + match at { + Some(r) => match try_worktree(repo, &r, ver) { + Some(wt) => { + let res = cargo_install_from(&wt); + let dest = cargo_dest_for(&safe_bin(&wt)).unwrap_or(dest); + cleanup_worktree(repo, &wt); + res?; + let sha = sha256_of(&dest).unwrap_or_default(); + emit_installed(fs, &dest, &sha, ver, "compile"); + Ok(()) + } + None => { + cargo_install_from(repo)?; + let sha = sha256_of(&dest).unwrap_or_default(); + emit_installed(fs, &dest, &sha, ver, "compile"); + Ok(()) + } + }, None => { cargo_install_from(repo)?; let sha = sha256_of(&dest).unwrap_or_default(); emit_installed(fs, &dest, &sha, ver, "compile"); Ok(()) } - }, - None => { - cargo_install_from(repo)?; - let sha = sha256_of(&dest).unwrap_or_default(); - emit_installed(fs, &dest, &sha, ver, "compile"); - Ok(()) } + } else { + Err(CliError::Runtime( + "no installer for this project (install.sh or Cargo.toml)".to_string(), + )) } } -/// Main install: compile the current checkout in place. +/// Main install: current checkout hands to install.sh or cargo. fn install_main( fs: &args::FlagSet, fg: &Forge, @@ -757,6 +798,20 @@ fn install_main( dry: bool, ) -> Result<(), CliError> { let _ = fg; + if repo.join("install.sh").is_file() { + if dry { + output::out("would run: ./install.sh main\n"); + return Ok(()); + } + run_install_sh(repo, "main")?; + emit_script(fs, "main"); + return Ok(()); + } + if !repo.join("Cargo.toml").is_file() { + return Err(CliError::Runtime( + "no installer for this project (install.sh or Cargo.toml)".to_string(), + )); + } let dest = cargo_dest_for(&safe_bin(repo))?; if dry { output::out(&format!( diff --git a/crates/cli/tests/install.rs b/crates/cli/tests/install.rs index 264ce83..58cb000 100644 --- a/crates/cli/tests/install.rs +++ b/crates/cli/tests/install.rs @@ -1,4 +1,4 @@ -//! #32: release install (latest|ver|main) + fast copy + dry-run + 03_release artifact. +//! #32/#49: release install resolve+stage+handoff + dry-run + 03_release artifact. use std::path::{Path, PathBuf}; use std::process::Command; @@ -59,17 +59,32 @@ fn git(dir: &Path, args: &[&str]) { assert!(st.success(), "git {args:?} failed"); } +fn cargo_manifest(dir: &Path) { + std::fs::write(dir.join("Cargo.toml"), "[package]\nname = \"f0rge\"\nversion = \"0.0.0\"\n").unwrap(); +} + +fn write_sh(dir: &Path, body: &str) { + let p = dir.join("install.sh"); + std::fs::write(&p, body).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap(); + } +} + #[test] fn install_dry_run_latest_and_main() { let dir = tmp("dry"); assert_eq!(run_in(&dir, &["init"], &[]).2, 0); + cargo_manifest(&dir); record(&dir, "v0.1.0", "2026-01-01T00:00:00Z"); record(&dir, "v0.1.1", "2026-01-02T00:00:00Z"); let home = dir.join("home"); let ch = home.join("cargo-home"); let dest = ch.join("bin").join("f0rge"); - // latest = newest record by (created, version): v0.1.1, no artifact/git -> compile dry-run. + // latest = newest record by (created, version): v0.1.1, cargo project -> compile dry-run. let (o, err, code) = run_in(&dir, &["release", "install", "--dry-run"], &[("CARGO_HOME", &ch.to_string_lossy())]); assert_eq!((code, err.as_str()), (0, ""), "stdout:\n{o}"); @@ -93,6 +108,7 @@ fn install_dry_run_latest_and_main() { fn install_ver_pinned_tag_optional_falls_back() { let dir = tmp("pin"); assert_eq!(run_in(&dir, &["init"], &[]).2, 0); + cargo_manifest(&dir); record(&dir, "v9.9.9", "2026-01-01T00:00:00Z"); git_init(&dir); git(&dir, &["tag", "v9.9.9"]); @@ -121,30 +137,68 @@ fn install_ver_pinned_tag_optional_falls_back() { } #[test] -fn install_copies_prebuilt_to_cargo_home() { - let dir = tmp("copy"); +fn install_sh_handoff_dry_and_real() { + // #49: project installer owns placement. + let dir = tmp("sh"); assert_eq!(run_in(&dir, &["init"], &[]).2, 0); - record(&dir, "v1.2.3", "2026-01-01T00:00:00Z"); - // prebuilt artifact for the resolved ref. - let art = dir.join(".forge/releases/build/v1.2.3/f0rge"); - std::fs::create_dir_all(art.parent().unwrap()).unwrap(); - std::fs::write(&art, b"fake-f0rge-binary").unwrap(); + write_sh(&dir, "#!/bin/sh\necho \"$1\" > ran.txt\necho \"$1 $PWD\" >> ran.txt\nexit 0\n"); + record(&dir, "v3.0.0", "2026-01-01T00:00:00Z"); + git_init(&dir); + git(&dir, &["tag", "v3.0.0"]); - let ch = dir.join("chome"); - let dest = ch.join("bin").join("f0rge"); - // latest == v1.2.3 -> fast path, real copy (no compile). - let (o, err, code) = - run_in(&dir, &["release", "install"], &[("CARGO_HOME", &ch.to_string_lossy())]); + let (o, err, code) = run_in(&dir, &["release", "install", "v3.0.0", "--dry-run"], &[]); assert_eq!((code, err.as_str()), (0, ""), "stdout:\n{o}"); - assert_eq!(std::fs::read(&dest).unwrap(), b"fake-f0rge-binary"); - assert!(o.contains("installed") && o.contains("v1.2.3"), "prints path+sha+ref:\n{o}"); - assert!(o.contains(&dest.display().to_string()), "prints dest path:\n{o}"); - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - assert!(std::fs::metadata(&dest).unwrap().permissions().mode() & 0o111 != 0, "chmod +x"); - } + assert!(o.contains("./install.sh") && o.contains("v3.0.0"), "dry exec:\n{o}"); + assert_eq!(o.lines().count(), 1, "one line:\n{o}"); + assert!(!dir.join("ran.txt").exists(), "dry-run touches nothing"); + + let (o, err, code) = run_in(&dir, &["release", "install", "v3.0.0"], &[]); + assert_eq!((code, err.as_str()), (0, ""), "stdout:\n{o}"); + assert!(o.contains("./install.sh") && o.contains("v3.0.0"), "ran:\n{o}"); + // script runs in a worktree for tags; check both checkout and worktree left no trace except local run marker. + // For current-checkout fallback the file lands in dir; for tag worktree it lands there (cleaned). + // Force a no-tag record to prove the local write path: + record(&dir, "v3.0.1", "2026-01-02T00:00:00Z"); + let (o, err, code) = run_in(&dir, &["release", "install", "v3.0.1"], &[]); + assert_eq!((code, err.as_str()), (0, ""), "stdout:\n{o}"); + let ran = std::fs::read_to_string(dir.join("ran.txt")).unwrap(); + assert!(ran.contains("v3.0.1"), "script got ver:\n{ran}"); + + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn install_cargo_only_dry() { + // #49: cargo project with no install.sh -> cargo path. + let dir = tmp("cargo"); + assert_eq!(run_in(&dir, &["init"], &[]).2, 0); + cargo_manifest(&dir); + assert!(!dir.join("install.sh").exists()); + record(&dir, "v4.0.0", "2026-01-01T00:00:00Z"); + git_init(&dir); + let (o, err, code) = run_in(&dir, &["release", "install", "v4.0.0", "--dry-run"], &[]); + assert_eq!((code, err.as_str()), (0, ""), "stdout:\n{o}"); + assert!(o.contains("cargo install"), "dry cargo:\n{o}"); + assert_eq!(o.lines().count(), 1, "one line:\n{o}"); + let _ = std::fs::remove_dir_all(&dir); +} +#[test] +fn install_no_installer_errors() { + // #49: neither install.sh nor Cargo.toml -> one line + exit 1. + let dir = tmp("none"); + assert_eq!(run_in(&dir, &["init"], &[]).2, 0); + assert!(!dir.join("install.sh").exists()); + assert!(!dir.join("Cargo.toml").exists()); + record(&dir, "v5.0.0", "2026-01-01T00:00:00Z"); + git_init(&dir); + for args in [&["release", "install", "v5.0.0"][..], &["release", "install", "v5.0.0", "--dry-run"][..]] { + let (o, err, code) = run_in(&dir, args, &[]); + assert_eq!(code, 1, "args {args:?} stdout:\n{o}\nstderr:\n{err}"); + assert_eq!(o, "", "stdout empty:\n{o}"); + assert_eq!(err.lines().count(), 1, "one line:\n{err}"); + assert!(err.contains("no installer for this project (install.sh or Cargo.toml)"), "stderr:\n{err}"); + } let _ = std::fs::remove_dir_all(&dir); } @@ -226,6 +280,7 @@ fn install_resolves_record_sha_with_no_tags() { // #47/#48: record sha wins with zero git tags present. let dir = tmp("rsha"); assert_eq!(run_in(&dir, &["init"], &[]).2, 0); + cargo_manifest(&dir); git_init(&dir); let sha = head_short(&dir); record_sha(&dir, "v0.9.9", "2026-01-01T00:00:00Z", &sha); @@ -239,36 +294,3 @@ fn install_resolves_record_sha_with_no_tags() { assert_eq!(o.lines().count(), 1, "one line:\n{o}"); let _ = std::fs::remove_dir_all(&dir); } - -#[test] -fn install_copies_lone_artifact_any_name() { - // #48: non-cargo project, one file under build// -> COPY it. - let dir = tmp("lone"); - assert_eq!(run_in(&dir, &["init"], &[]).2, 0); - assert!(!dir.join("Cargo.toml").exists()); - record(&dir, "v2.0.0", "2026-01-01T00:00:00Z"); - let art = dir.join(".forge/releases/build/v2.0.0/onefile"); - std::fs::create_dir_all(art.parent().unwrap()).unwrap(); - std::fs::write(&art, b"#!/bin/sh\necho hi\n").unwrap(); - - let ch = dir.join("chome"); - let dest = ch.join("bin").join("onefile"); - let (o, err, code) = - run_in(&dir, &["release", "install", "v2.0.0", "--dry-run"], &[("CARGO_HOME", &ch.to_string_lossy())]); - assert_eq!((code, err.as_str()), (0, ""), "stdout:\n{o}"); - assert!(o.contains("would copy") && o.contains("onefile"), "dry copy:\n{o}"); - assert!(o.contains(&dest.display().to_string()), "dry dest:\n{o}"); - assert!(!dest.exists(), "dry-run touches nothing"); - - let (o, err, code) = - run_in(&dir, &["release", "install", "v2.0.0"], &[("CARGO_HOME", &ch.to_string_lossy())]); - assert_eq!((code, err.as_str()), (0, ""), "stdout:\n{o}"); - assert_eq!(std::fs::read(&dest).unwrap(), b"#!/bin/sh\necho hi\n"); - assert!(o.contains("installed") && o.contains("v2.0.0"), "prints path+sha+ref:\n{o}"); - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - assert!(std::fs::metadata(&dest).unwrap().permissions().mode() & 0o111 != 0, "chmod +x"); - } - let _ = std::fs::remove_dir_all(&dir); -} diff --git a/spec/schema.md b/spec/schema.md index 09dad17..f094244 100644 --- a/spec/schema.md +++ b/spec/schema.md @@ -144,13 +144,14 @@ Golden: `spec/fixtures/ci-run.md`. prints the notes body. No delete verb (nothing is deleted). - Install (local machine op; never forge state, never CI): `release install` (latest record), `release install ` (pinned: - tag `` must exist else `no such release: `, exit 2), - `release install main` (current checkout). Fast path: copy prebuilt - `releases/build//f0rge` to `$CARGO_HOME/bin` (else - `~/.cargo/bin`), chmod +x; else `cargo install --locked` from a - temp tag checkout (tags) or the current tree (main). Prints - `installed `. `--dry-run` prints each step - (`would ...`, one line each) without touching the machine. + record must exist else `no such release: `, exit 2), + `release install main` (current checkout). Resolve record sha > + git tag > current checkout; stage `releases/build//`; if + repo-root `install.sh` exists run it argv-only as `./install.sh + ` (it owns placement); else if `Cargo.toml` exists `cargo + install --locked` from the resolved tree; else `no installer for + this project (install.sh or Cargo.toml)` (exit 1). `--dry-run` + prints the path it would take (one line) without touching the machine. - Build artifact: CI copies `target/release/f0rge` to `releases/build//f0rge` per release record (gitignored, never committed); `status: built|none` still derives from -- 2.47.3