diff --git a/.forge/prs/f0dcf06d-f1f9-4079-a6fb-2b2bc5f251d8/patch.diff b/.forge/prs/f0dcf06d-f1f9-4079-a6fb-2b2bc5f251d8/patch.diffnew file mode 100644index 0000000..a6fa4ea--- /dev/null+++ b/.forge/prs/f0dcf06d-f1f9-4079-a6fb-2b2bc5f251d8/patch.diff@@ -0,0 +1,489 @@1+From 78893d0209aa55f7bff0cef36bbb69c1bd783b69 Mon Sep 17 00:00:00 20012+From: z <gl0w@world>3+Date: Wed, 9 Sep 2026 01:49:16 +01004+Subject: [PATCH] install: handoff to project installer, drop generic copy (#495+ 97eeb419-d05e-49b2-b23a-e1ccef967219)6+7+---8+ .forge/wiki/docs/AGENTS.md | 7 +-9+ crates/cli/src/cmd/release.rs | 191 ++++++++++++++++++++++------------10+ crates/cli/tests/install.rs | 132 +++++++++++++----------11+ spec/schema.md | 15 +--12+ 4 files changed, 209 insertions(+), 136 deletions(-)13+14+diff --git a/.forge/wiki/docs/AGENTS.md b/.forge/wiki/docs/AGENTS.md15+index dd93130..21e33d9 10064416+--- a/.forge/wiki/docs/AGENTS.md17++++ b/.forge/wiki/docs/AGENTS.md18+@@ -19,12 +19,7 @@ dogfoods: building f0rge is the first f0rge project (from PLAN M3 on).19+ - moving tip: `f0rge release install main` (builds current checkout)20+ - preview only: append `--dry-run` (prints each step, changes nothing)21+ 22+-A lone file under `.forge/releases/build/<ver>/` copies to23+-`$CARGO_HOME/bin/<its name>` (any project, any language); otherwise24+-the resolved tree compiles via `cargo install --locked` (bin name from25+-the manifest). f0rge is the example: it drops26+-`.forge/releases/build/<ver>/f0rge`, and a fresh machine starts with27+-`cargo install --locked --path .` from the clone.28++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 <ver>`; 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.29+ 30+ ## Reading order31+ 32+diff --git a/crates/cli/src/cmd/release.rs b/crates/cli/src/cmd/release.rs33+index f05aadc..0f3383b 10064434+--- a/crates/cli/src/cmd/release.rs35++++ b/crates/cli/src/cmd/release.rs36+@@ -592,22 +592,50 @@ fn cargo_install_from(src: &Path) -> Result<(), CliError> {37+ }38+ }39+ 40+-/// Lone prebuilt file under releases/build/<ver>/ (any name, any41+-/// language); Some(path) only when exactly one file sits there (#48).42+-fn single_artifact(dir: &Path) -> Option<PathBuf> {43+- let rd = std::fs::read_dir(dir).ok()?;44+- let mut files = Vec::new();45+- for e in rd.flatten() {46+- let ft = match e.file_type() {47+- Ok(t) => t,48+- Err(_) => continue,49+- };50+- if ft.is_symlink() || !ft.is_file() {51+- continue;52+- }53+- files.push(e.path());54++/// Installer convention (#49): repo-root `install.sh` run argv-only55++/// as `./install.sh <ver>` owns placement for its ecosystem.56++fn ref_has_file(repo: &Path, gitref: &str, name: &str) -> bool {57++ Command::new("git")58++ .arg("-C")59++ .arg(repo)60++ .args(["cat-file", "-e", &format!("{gitref}:{name}")])61++ .stdout(std::process::Stdio::null())62++ .stderr(std::process::Stdio::null())63++ .status()64++ .map(|s| s.success())65++ .unwrap_or(false)66++}67++68++fn resolved_has(repo: &Path, at: &Option<String>, dir: &Path, name: &str) -> bool {69++ match at {70++ Some(r) => ref_has_file(repo, r, name),71++ None => dir.join(name).is_file(),72+ }73+- if files.len() == 1 { files.into_iter().next() } else { None }74++}75++76++/// Run the project installer argv-only, no shell (SEC-04, local posture).77++fn run_install_sh(dir: &Path, ver: &str) -> Result<(), CliError> {78++ let st = Command::new(dir.join("install.sh")).arg(ver).current_dir(dir).status();79++ match st {80++ Ok(s) if s.success() => Ok(()),81++ _ => Err(CliError::Runtime("install failed".to_string())),82++ }83++}84++85++fn emit_script(fs: &args::FlagSet, ver: &str) {86++ if fs.is("json") {87++ output::emit_json(88++ &InstallJson {89++ path: "./install.sh".to_string(),90++ sha256: String::new(),91++ ref_: ver.to_string(),92++ method: "install.sh".to_string(),93++ },94++ fs.get("jq"),95++ );96++ return;97++ }98++ output::out(&format!("ran ./install.sh {ver}\n"));99+ }100+ 101+ /// Bin name from a Cargo manifest: first [[bin]].name, else102+@@ -667,9 +695,10 @@ fn safe_bin(dir: &Path) -> String {103+ }104+ }105+ 106+-/// Pinned install (#48): a lone build artifact copies to107+-/// $CARGO_HOME/bin/<its name>; else cargo install from the resolved108+-/// tree (record sha > git tag > current checkout). Argv-only throughout.109++/// Pinned install (#49): resolve record sha > git tag > current110++/// checkout, stage releases/build/<ver>/, then hand off: repo-root111++/// install.sh argv-only as ./install.sh <ver>, else cargo install112++/// for cargo projects, else one-line no-installer error. Argv-only.113+ fn install_ver(114+ fs: &args::FlagSet,115+ fg: &Forge,116+@@ -677,32 +706,6 @@ fn install_ver(117+ ver: &str,118+ dry: bool,119+ ) -> Result<(), CliError> {120+- let build_dir = fg.root().join("releases").join("build").join(ver);121+- if let Some(art) = single_artifact(&build_dir) {122+- let name = art123+- .file_name()124+- .map(|s| s.to_string_lossy().into_owned())125+- .unwrap_or_else(|| "f0rge".to_string());126+- let dest = cargo_dest_for(&name)?;127+- if dry {128+- output::out(&format!("would copy {} to {}\n", art.display(), dest.display()));129+- return Ok(());130+- }131+- if let Some(p) = dest.parent() {132+- let _ = std::fs::create_dir_all(p);133+- }134+- std::fs::copy(&art, &dest)135+- .map_err(|_| CliError::Runtime("install failed".to_string()))?;136+- #[cfg(unix)]137+- {138+- use std::os::unix::fs::PermissionsExt;139+- let _ =140+- std::fs::set_permissions(&dest, std::fs::Permissions::from_mode(0o755));141+- }142+- let sha = sha256_of(&dest).unwrap_or_default();143+- emit_installed(fs, &dest, &sha, ver, "copy");144+- return Ok(());145+- }146+ let sha = record_sha(fg, ver);147+ let tag_ok = tag_exists(repo, ver);148+ let sha_ok = sha.as_ref().is_some_and(|s| tag_exists(repo, s));149+@@ -714,42 +717,80 @@ fn install_ver(150+ } else {151+ (None, format!("{ver} (current checkout)"))152+ };153+- let dest = cargo_dest_for(&safe_bin(repo))?;154+- if dry {155+- output::out(&format!(156+- "would run: cargo install --locked --path <checkout of {label}> to {}\n",157+- dest.display()158+- ));159+- return Ok(());160+- }161+- match at {162+- Some(r) => match try_worktree(repo, &r, ver) {163+- Some(wt) => {164+- let res = cargo_install_from(&wt);165+- let dest = cargo_dest_for(&safe_bin(&wt)).unwrap_or(dest);166+- cleanup_worktree(repo, &wt);167+- res?;168+- let sha = sha256_of(&dest).unwrap_or_default();169+- emit_installed(fs, &dest, &sha, ver, "compile");170++ let has_sh = resolved_has(repo, &at, repo, "install.sh");171++ let has_cargo = resolved_has(repo, &at, repo, "Cargo.toml");172++ if has_sh {173++ if dry {174++ output::out(&format!("would run: ./install.sh {ver} (checkout of {label})\n"));175++ return Ok(());176++ }177++ let build_dir = fg.root().join("releases").join("build").join(ver);178++ let _ = std::fs::create_dir_all(&build_dir);179++ match at {180++ Some(r) => match try_worktree(repo, &r, ver) {181++ Some(wt) => {182++ let res = run_install_sh(&wt, ver);183++ cleanup_worktree(repo, &wt);184++ res?;185++ emit_script(fs, ver);186++ Ok(())187++ }188++ None => {189++ run_install_sh(repo, ver)?;190++ emit_script(fs, ver);191++ Ok(())192++ }193++ },194++ None => {195++ run_install_sh(repo, ver)?;196++ emit_script(fs, ver);197+ Ok(())198+ }199++ }200++ } else if has_cargo {201++ let dest = cargo_dest_for(&safe_bin(repo))?;202++ if dry {203++ output::out(&format!(204++ "would run: cargo install --locked --path <checkout of {label}> to {}\n",205++ dest.display()206++ ));207++ return Ok(());208++ }209++ let build_dir = fg.root().join("releases").join("build").join(ver);210++ let _ = std::fs::create_dir_all(&build_dir);211++ match at {212++ Some(r) => match try_worktree(repo, &r, ver) {213++ Some(wt) => {214++ let res = cargo_install_from(&wt);215++ let dest = cargo_dest_for(&safe_bin(&wt)).unwrap_or(dest);216++ cleanup_worktree(repo, &wt);217++ res?;218++ let sha = sha256_of(&dest).unwrap_or_default();219++ emit_installed(fs, &dest, &sha, ver, "compile");220++ Ok(())221++ }222++ None => {223++ cargo_install_from(repo)?;224++ let sha = sha256_of(&dest).unwrap_or_default();225++ emit_installed(fs, &dest, &sha, ver, "compile");226++ Ok(())227++ }228++ },229+ None => {230+ cargo_install_from(repo)?;231+ let sha = sha256_of(&dest).unwrap_or_default();232+ emit_installed(fs, &dest, &sha, ver, "compile");233+ Ok(())234+ }235+- },236+- None => {237+- cargo_install_from(repo)?;238+- let sha = sha256_of(&dest).unwrap_or_default();239+- emit_installed(fs, &dest, &sha, ver, "compile");240+- Ok(())241+ }242++ } else {243++ Err(CliError::Runtime(244++ "no installer for this project (install.sh or Cargo.toml)".to_string(),245++ ))246+ }247+ }248+ 249+-/// Main install: compile the current checkout in place.250++/// Main install: current checkout hands to install.sh or cargo.251+ fn install_main(252+ fs: &args::FlagSet,253+ fg: &Forge,254+@@ -757,6 +798,20 @@ fn install_main(255+ dry: bool,256+ ) -> Result<(), CliError> {257+ let _ = fg;258++ if repo.join("install.sh").is_file() {259++ if dry {260++ output::out("would run: ./install.sh main\n");261++ return Ok(());262++ }263++ run_install_sh(repo, "main")?;264++ emit_script(fs, "main");265++ return Ok(());266++ }267++ if !repo.join("Cargo.toml").is_file() {268++ return Err(CliError::Runtime(269++ "no installer for this project (install.sh or Cargo.toml)".to_string(),270++ ));271++ }272+ let dest = cargo_dest_for(&safe_bin(repo))?;273+ if dry {274+ output::out(&format!(275+diff --git a/crates/cli/tests/install.rs b/crates/cli/tests/install.rs276+index 264ce83..58cb000 100644277+--- a/crates/cli/tests/install.rs278++++ b/crates/cli/tests/install.rs279+@@ -1,4 +1,4 @@280+-//! #32: release install (latest|ver|main) + fast copy + dry-run + 03_release artifact.281++//! #32/#49: release install resolve+stage+handoff + dry-run + 03_release artifact.282+ 283+ use std::path::{Path, PathBuf};284+ use std::process::Command;285+@@ -59,17 +59,32 @@ fn git(dir: &Path, args: &[&str]) {286+ assert!(st.success(), "git {args:?} failed");287+ }288+ 289++fn cargo_manifest(dir: &Path) {290++ std::fs::write(dir.join("Cargo.toml"), "[package]\nname = \"f0rge\"\nversion = \"0.0.0\"\n").unwrap();291++}292++293++fn write_sh(dir: &Path, body: &str) {294++ let p = dir.join("install.sh");295++ std::fs::write(&p, body).unwrap();296++ #[cfg(unix)]297++ {298++ use std::os::unix::fs::PermissionsExt;299++ std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap();300++ }301++}302++303+ #[test]304+ fn install_dry_run_latest_and_main() {305+ let dir = tmp("dry");306+ assert_eq!(run_in(&dir, &["init"], &[]).2, 0);307++ cargo_manifest(&dir);308+ record(&dir, "v0.1.0", "2026-01-01T00:00:00Z");309+ record(&dir, "v0.1.1", "2026-01-02T00:00:00Z");310+ let home = dir.join("home");311+ let ch = home.join("cargo-home");312+ let dest = ch.join("bin").join("f0rge");313+ 314+- // latest = newest record by (created, version): v0.1.1, no artifact/git -> compile dry-run.315++ // latest = newest record by (created, version): v0.1.1, cargo project -> compile dry-run.316+ let (o, err, code) =317+ run_in(&dir, &["release", "install", "--dry-run"], &[("CARGO_HOME", &ch.to_string_lossy())]);318+ assert_eq!((code, err.as_str()), (0, ""), "stdout:\n{o}");319+@@ -93,6 +108,7 @@ fn install_dry_run_latest_and_main() {320+ fn install_ver_pinned_tag_optional_falls_back() {321+ let dir = tmp("pin");322+ assert_eq!(run_in(&dir, &["init"], &[]).2, 0);323++ cargo_manifest(&dir);324+ record(&dir, "v9.9.9", "2026-01-01T00:00:00Z");325+ git_init(&dir);326+ git(&dir, &["tag", "v9.9.9"]);327+@@ -121,30 +137,68 @@ fn install_ver_pinned_tag_optional_falls_back() {328+ }329+ 330+ #[test]331+-fn install_copies_prebuilt_to_cargo_home() {332+- let dir = tmp("copy");333++fn install_sh_handoff_dry_and_real() {334++ // #49: project installer owns placement.335++ let dir = tmp("sh");336+ assert_eq!(run_in(&dir, &["init"], &[]).2, 0);337+- record(&dir, "v1.2.3", "2026-01-01T00:00:00Z");338+- // prebuilt artifact for the resolved ref.339+- let art = dir.join(".forge/releases/build/v1.2.3/f0rge");340+- std::fs::create_dir_all(art.parent().unwrap()).unwrap();341+- std::fs::write(&art, b"fake-f0rge-binary").unwrap();342++ write_sh(&dir, "#!/bin/sh\necho \"$1\" > ran.txt\necho \"$1 $PWD\" >> ran.txt\nexit 0\n");343++ record(&dir, "v3.0.0", "2026-01-01T00:00:00Z");344++ git_init(&dir);345++ git(&dir, &["tag", "v3.0.0"]);346+ 347+- let ch = dir.join("chome");348+- let dest = ch.join("bin").join("f0rge");349+- // latest == v1.2.3 -> fast path, real copy (no compile).350+- let (o, err, code) =351+- run_in(&dir, &["release", "install"], &[("CARGO_HOME", &ch.to_string_lossy())]);352++ let (o, err, code) = run_in(&dir, &["release", "install", "v3.0.0", "--dry-run"], &[]);353+ assert_eq!((code, err.as_str()), (0, ""), "stdout:\n{o}");354+- assert_eq!(std::fs::read(&dest).unwrap(), b"fake-f0rge-binary");355+- assert!(o.contains("installed") && o.contains("v1.2.3"), "prints path+sha+ref:\n{o}");356+- assert!(o.contains(&dest.display().to_string()), "prints dest path:\n{o}");357+- #[cfg(unix)]358+- {359+- use std::os::unix::fs::PermissionsExt;360+- assert!(std::fs::metadata(&dest).unwrap().permissions().mode() & 0o111 != 0, "chmod +x");361+- }362++ assert!(o.contains("./install.sh") && o.contains("v3.0.0"), "dry exec:\n{o}");363++ assert_eq!(o.lines().count(), 1, "one line:\n{o}");364++ assert!(!dir.join("ran.txt").exists(), "dry-run touches nothing");365++366++ let (o, err, code) = run_in(&dir, &["release", "install", "v3.0.0"], &[]);367++ assert_eq!((code, err.as_str()), (0, ""), "stdout:\n{o}");368++ assert!(o.contains("./install.sh") && o.contains("v3.0.0"), "ran:\n{o}");369++ // script runs in a worktree for tags; check both checkout and worktree left no trace except local run marker.370++ // For current-checkout fallback the file lands in dir; for tag worktree it lands there (cleaned).371++ // Force a no-tag record to prove the local write path:372++ record(&dir, "v3.0.1", "2026-01-02T00:00:00Z");373++ let (o, err, code) = run_in(&dir, &["release", "install", "v3.0.1"], &[]);374++ assert_eq!((code, err.as_str()), (0, ""), "stdout:\n{o}");375++ let ran = std::fs::read_to_string(dir.join("ran.txt")).unwrap();376++ assert!(ran.contains("v3.0.1"), "script got ver:\n{ran}");377++378++ let _ = std::fs::remove_dir_all(&dir);379++}380++381++#[test]382++fn install_cargo_only_dry() {383++ // #49: cargo project with no install.sh -> cargo path.384++ let dir = tmp("cargo");385++ assert_eq!(run_in(&dir, &["init"], &[]).2, 0);386++ cargo_manifest(&dir);387++ assert!(!dir.join("install.sh").exists());388++ record(&dir, "v4.0.0", "2026-01-01T00:00:00Z");389++ git_init(&dir);390++ let (o, err, code) = run_in(&dir, &["release", "install", "v4.0.0", "--dry-run"], &[]);391++ assert_eq!((code, err.as_str()), (0, ""), "stdout:\n{o}");392++ assert!(o.contains("cargo install"), "dry cargo:\n{o}");393++ assert_eq!(o.lines().count(), 1, "one line:\n{o}");394++ let _ = std::fs::remove_dir_all(&dir);395++}396+ 397++#[test]398++fn install_no_installer_errors() {399++ // #49: neither install.sh nor Cargo.toml -> one line + exit 1.400++ let dir = tmp("none");401++ assert_eq!(run_in(&dir, &["init"], &[]).2, 0);402++ assert!(!dir.join("install.sh").exists());403++ assert!(!dir.join("Cargo.toml").exists());404++ record(&dir, "v5.0.0", "2026-01-01T00:00:00Z");405++ git_init(&dir);406++ for args in [&["release", "install", "v5.0.0"][..], &["release", "install", "v5.0.0", "--dry-run"][..]] {407++ let (o, err, code) = run_in(&dir, args, &[]);408++ assert_eq!(code, 1, "args {args:?} stdout:\n{o}\nstderr:\n{err}");409++ assert_eq!(o, "", "stdout empty:\n{o}");410++ assert_eq!(err.lines().count(), 1, "one line:\n{err}");411++ assert!(err.contains("no installer for this project (install.sh or Cargo.toml)"), "stderr:\n{err}");412++ }413+ let _ = std::fs::remove_dir_all(&dir);414+ }415+ 416+@@ -226,6 +280,7 @@ fn install_resolves_record_sha_with_no_tags() {417+ // #47/#48: record sha wins with zero git tags present.418+ let dir = tmp("rsha");419+ assert_eq!(run_in(&dir, &["init"], &[]).2, 0);420++ cargo_manifest(&dir);421+ git_init(&dir);422+ let sha = head_short(&dir);423+ record_sha(&dir, "v0.9.9", "2026-01-01T00:00:00Z", &sha);424+@@ -239,36 +294,3 @@ fn install_resolves_record_sha_with_no_tags() {425+ assert_eq!(o.lines().count(), 1, "one line:\n{o}");426+ let _ = std::fs::remove_dir_all(&dir);427+ }428+-429+-#[test]430+-fn install_copies_lone_artifact_any_name() {431+- // #48: non-cargo project, one file under build/<ver>/ -> COPY it.432+- let dir = tmp("lone");433+- assert_eq!(run_in(&dir, &["init"], &[]).2, 0);434+- assert!(!dir.join("Cargo.toml").exists());435+- record(&dir, "v2.0.0", "2026-01-01T00:00:00Z");436+- let art = dir.join(".forge/releases/build/v2.0.0/onefile");437+- std::fs::create_dir_all(art.parent().unwrap()).unwrap();438+- std::fs::write(&art, b"#!/bin/sh\necho hi\n").unwrap();439+-440+- let ch = dir.join("chome");441+- let dest = ch.join("bin").join("onefile");442+- let (o, err, code) =443+- run_in(&dir, &["release", "install", "v2.0.0", "--dry-run"], &[("CARGO_HOME", &ch.to_string_lossy())]);444+- assert_eq!((code, err.as_str()), (0, ""), "stdout:\n{o}");445+- assert!(o.contains("would copy") && o.contains("onefile"), "dry copy:\n{o}");446+- assert!(o.contains(&dest.display().to_string()), "dry dest:\n{o}");447+- assert!(!dest.exists(), "dry-run touches nothing");448+-449+- let (o, err, code) =450+- run_in(&dir, &["release", "install", "v2.0.0"], &[("CARGO_HOME", &ch.to_string_lossy())]);451+- assert_eq!((code, err.as_str()), (0, ""), "stdout:\n{o}");452+- assert_eq!(std::fs::read(&dest).unwrap(), b"#!/bin/sh\necho hi\n");453+- assert!(o.contains("installed") && o.contains("v2.0.0"), "prints path+sha+ref:\n{o}");454+- #[cfg(unix)]455+- {456+- use std::os::unix::fs::PermissionsExt;457+- assert!(std::fs::metadata(&dest).unwrap().permissions().mode() & 0o111 != 0, "chmod +x");458+- }459+- let _ = std::fs::remove_dir_all(&dir);460+-}461+diff --git a/spec/schema.md b/spec/schema.md462+index 09dad17..f094244 100644463+--- a/spec/schema.md464++++ b/spec/schema.md465+@@ -144,13 +144,14 @@ Golden: `spec/fixtures/ci-run.md`.466+ prints the notes body. No delete verb (nothing is deleted).467+ - Install (local machine op; never forge state, never CI):468+ `release install` (latest record), `release install <ver>` (pinned:469+- tag `<ver>` must exist else `no such release: <ver>`, exit 2),470+- `release install main` (current checkout). Fast path: copy prebuilt471+- `releases/build/<ver>/f0rge` to `$CARGO_HOME/bin` (else472+- `~/.cargo/bin`), chmod +x; else `cargo install --locked` from a473+- temp tag checkout (tags) or the current tree (main). Prints474+- `installed <path> <sha256> <ref>`. `--dry-run` prints each step475+- (`would ...`, one line each) without touching the machine.476++ record must exist else `no such release: <ver>`, exit 2),477++ `release install main` (current checkout). Resolve record sha >478++ git tag > current checkout; stage `releases/build/<ver>/`; if479++ repo-root `install.sh` exists run it argv-only as `./install.sh480++ <ver>` (it owns placement); else if `Cargo.toml` exists `cargo481++ install --locked` from the resolved tree; else `no installer for482++ this project (install.sh or Cargo.toml)` (exit 1). `--dry-run`483++ prints the path it would take (one line) without touching the machine.484+ - Build artifact: CI copies `target/release/f0rge` to485+ `releases/build/<ver>/f0rge` per release record (gitignored,486+ never committed); `status: built|none` still derives from487+-- 488+2.47.3489+