merge 3911acb7-599f-41c3-8308-80776f41ca6c

13f8645gl0w

all commits

Message

merge 3911acb7-599f-41c3-8308-80776f41ca6c

Diff

.forge/prs/f0dcf06d-f1f9-4079-a6fb-2b2bc5f251d8/patch.diff+489 −0
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+
.forge/prs/f0dcf06d-f1f9-4079-a6fb-2b2bc5f251d8/pr.md+13 −0
diff --git a/.forge/prs/f0dcf06d-f1f9-4079-a6fb-2b2bc5f251d8/pr.md b/.forge/prs/f0dcf06d-f1f9-4079-a6fb-2b2bc5f251d8/pr.mdnew file mode 100644index 0000000..1be7670--- /dev/null+++ b/.forge/prs/f0dcf06d-f1f9-4079-a6fb-2b2bc5f251d8/pr.md@@ -0,0 +1,13 @@1+---2+id: f0dcf06d-f1f9-4079-a6fb-2b2bc5f251d83+title: fix-install-place4+state: open5+base: main6+head: fix-install-place7+draft: false8+issues:9+- 97eeb419-d05e-49b2-b23a-e1ccef96721910+ci: null11+created: 2026-09-09T00:49:18.539989024Z12+updated: null13+---
.forge/wiki/docs/AGENTS.md+1 −6
diff --git a/.forge/wiki/docs/AGENTS.md b/.forge/wiki/docs/AGENTS.mdindex 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).1919 - moving tip: `f0rge release install main` (builds current checkout)2020 - preview only: append `--dry-run` (prints each step, changes nothing)2121 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.22+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.2823 2924 ## Reading order3025 
crates/cli/src/cmd/release.rs+97 −70
diff --git a/crates/cli/src/cmd/release.rs b/crates/cli/src/cmd/release.rsindex f05aadc..f415d94 100644--- a/crates/cli/src/cmd/release.rs+++ b/crates/cli/src/cmd/release.rs@@ -592,22 +592,37 @@ fn cargo_install_from(src: &Path) -> Result<(), CliError> {592592     }593593 }594594 595-/// Lone prebuilt file under releases/build/<ver>/ (any name, any596-/// language); Some(path) only when exactly one file sits there (#48).597-fn single_artifact(dir: &Path) -> Option<PathBuf> {598-    let rd = std::fs::read_dir(dir).ok()?;599-    let mut files = Vec::new();600-    for e in rd.flatten() {601-        let ft = match e.file_type() {602-            Ok(t) => t,603-            Err(_) => continue,604-        };605-        if ft.is_symlink() || !ft.is_file() {606-            continue;607-        }608-        files.push(e.path());595+/// Installer convention (#49): CWD repo-root `install.sh` run argv-only596+/// as `./install.sh <ver>` owns placement for its ecosystem.597+/// Detection + exec are CWD-based; record-sha/tag pin only the598+/// version (and cargo source tree / staging), never the installer.599+/// Run the project installer argv-only, no shell (SEC-04, local posture).600+fn run_install_sh(dir: &Path, ver: &str, env_sha: &str) -> Result<(), CliError> {601+    let st = Command::new(dir.join("install.sh"))602+        .arg(ver)603+        .current_dir(dir)604+        .env("F0RGE_RELEASE_SHA", env_sha)605+        .status();606+    match st {607+        Ok(s) if s.success() => Ok(()),608+        _ => Err(CliError::Runtime("install failed".to_string())),609609     }610-    if files.len() == 1 { files.into_iter().next() } else { None }610+}611+612+fn emit_script(fs: &args::FlagSet, ver: &str) {613+    if fs.is("json") {614+        output::emit_json(615+            &InstallJson {616+                path: "./install.sh".to_string(),617+                sha256: String::new(),618+                ref_: ver.to_string(),619+                method: "install.sh".to_string(),620+            },621+            fs.get("jq"),622+        );623+        return;624+    }625+    output::out(&format!("ran ./install.sh {ver}\n"));611626 }612627 613628 /// Bin name from a Cargo manifest: first [[bin]].name, else@@ -667,9 +682,12 @@ fn safe_bin(dir: &Path) -> String {667682     }668683 }669684 670-/// Pinned install (#48): a lone build artifact copies to671-/// $CARGO_HOME/bin/<its name>; else cargo install from the resolved672-/// tree (record sha > git tag > current checkout). Argv-only throughout.685+/// Pinned install (#49): resolve record sha > git tag > current686+/// checkout for pin + stage releases/build/<ver>/, then hand off687+/// CWD-based: CWD ./install.sh argv-only as ./install.sh <ver>688+/// (F0RGE_RELEASE_SHA env carries the pin), else cargo install689+/// from the resolved tree for CWD cargo projects, else one-line690+/// no-installer error. Argv-only.673691 fn install_ver(674692     fs: &args::FlagSet,675693     fg: &Forge,@@ -677,32 +695,6 @@ fn install_ver(677695     ver: &str,678696     dry: bool,679697 ) -> Result<(), CliError> {680-    let build_dir = fg.root().join("releases").join("build").join(ver);681-    if let Some(art) = single_artifact(&build_dir) {682-        let name = art683-            .file_name()684-            .map(|s| s.to_string_lossy().into_owned())685-            .unwrap_or_else(|| "f0rge".to_string());686-        let dest = cargo_dest_for(&name)?;687-        if dry {688-            output::out(&format!("would copy {} to {}\n", art.display(), dest.display()));689-            return Ok(());690-        }691-        if let Some(p) = dest.parent() {692-            let _ = std::fs::create_dir_all(p);693-        }694-        std::fs::copy(&art, &dest)695-            .map_err(|_| CliError::Runtime("install failed".to_string()))?;696-        #[cfg(unix)]697-        {698-            use std::os::unix::fs::PermissionsExt;699-            let _ =700-                std::fs::set_permissions(&dest, std::fs::Permissions::from_mode(0o755));701-        }702-        let sha = sha256_of(&dest).unwrap_or_default();703-        emit_installed(fs, &dest, &sha, ver, "copy");704-        return Ok(());705-    }706698     let sha = record_sha(fg, ver);707699     let tag_ok = tag_exists(repo, ver);708700     let sha_ok = sha.as_ref().is_some_and(|s| tag_exists(repo, s));@@ -714,42 +706,63 @@ fn install_ver(714706     } else {715707         (None, format!("{ver} (current checkout)"))716708     };717-    let dest = cargo_dest_for(&safe_bin(repo))?;718-    if dry {719-        output::out(&format!(720-            "would run: cargo install --locked --path <checkout of {label}> to {}\n",721-            dest.display()722-        ));723-        return Ok(());724-    }725-    match at {726-        Some(r) => match try_worktree(repo, &r, ver) {727-            Some(wt) => {728-                let res = cargo_install_from(&wt);729-                let dest = cargo_dest_for(&safe_bin(&wt)).unwrap_or(dest);730-                cleanup_worktree(repo, &wt);731-                res?;732-                let sha = sha256_of(&dest).unwrap_or_default();733-                emit_installed(fs, &dest, &sha, ver, "compile");734-                Ok(())735-            }709+    let has_sh = repo.join("install.sh").is_file();710+    let has_cargo = repo.join("Cargo.toml").is_file();711+    if has_sh {712+        if dry {713+            output::out(&format!("would run: ./install.sh {ver} (checkout of {label})\n"));714+            return Ok(());715+        }716+        let build_dir = fg.root().join("releases").join("build").join(ver);717+        let _ = std::fs::create_dir_all(&build_dir);718+        let env_sha = sha.clone().or(at.clone()).unwrap_or_default();719+        run_install_sh(repo, ver, &env_sha)?;720+        emit_script(fs, ver);721+        Ok(())722+    } else if has_cargo {723+        let dest = cargo_dest_for(&safe_bin(repo))?;724+        if dry {725+            output::out(&format!(726+                "would run: cargo install --locked --path <checkout of {label}> to {}\n",727+                dest.display()728+            ));729+            return Ok(());730+        }731+        let build_dir = fg.root().join("releases").join("build").join(ver);732+        let _ = std::fs::create_dir_all(&build_dir);733+        match at {734+            Some(r) => match try_worktree(repo, &r, ver) {735+                Some(wt) => {736+                    let res = cargo_install_from(&wt);737+                    let dest = cargo_dest_for(&safe_bin(&wt)).unwrap_or(dest);738+                    cleanup_worktree(repo, &wt);739+                    res?;740+                    let sha = sha256_of(&dest).unwrap_or_default();741+                    emit_installed(fs, &dest, &sha, ver, "compile");742+                    Ok(())743+                }744+                None => {745+                    cargo_install_from(repo)?;746+                    let sha = sha256_of(&dest).unwrap_or_default();747+                    emit_installed(fs, &dest, &sha, ver, "compile");748+                    Ok(())749+                }750+            },736751             None => {737752                 cargo_install_from(repo)?;738753                 let sha = sha256_of(&dest).unwrap_or_default();739754                 emit_installed(fs, &dest, &sha, ver, "compile");740755                 Ok(())741756             }742-        },743-        None => {744-            cargo_install_from(repo)?;745-            let sha = sha256_of(&dest).unwrap_or_default();746-            emit_installed(fs, &dest, &sha, ver, "compile");747-            Ok(())748757         }758+    } else {759+        Err(CliError::Runtime(760+            "no installer for this project (install.sh or Cargo.toml)".to_string(),761+        ))749762     }750763 }751764 752-/// Main install: compile the current checkout in place.765+/// Main install: current checkout hands to install.sh or cargo.753766 fn install_main(754767     fs: &args::FlagSet,755768     fg: &Forge,@@ -757,6 +770,20 @@ fn install_main(757770     dry: bool,758771 ) -> Result<(), CliError> {759772     let _ = fg;773+    if repo.join("install.sh").is_file() {774+        if dry {775+            output::out("would run: ./install.sh main\n");776+            return Ok(());777+        }778+        run_install_sh(repo, "main", "main")?;779+        emit_script(fs, "main");780+        return Ok(());781+    }782+    if !repo.join("Cargo.toml").is_file() {783+        return Err(CliError::Runtime(784+            "no installer for this project (install.sh or Cargo.toml)".to_string(),785+        ));786+    }760787     let dest = cargo_dest_for(&safe_bin(repo))?;761788     if dry {762789         output::out(&format!(
crates/cli/tests/install.rs+75 −55
diff --git a/crates/cli/tests/install.rs b/crates/cli/tests/install.rsindex 264ce83..dfe04de 100644--- a/crates/cli/tests/install.rs+++ b/crates/cli/tests/install.rs@@ -1,4 +1,4 @@1-//! #32: release install (latest|ver|main) + fast copy + dry-run + 03_release artifact.1+//! #32/#49: release install resolve+stage+handoff + dry-run + 03_release artifact.22 33 use std::path::{Path, PathBuf};44 use std::process::Command;@@ -59,17 +59,32 @@ fn git(dir: &Path, args: &[&str]) {5959     assert!(st.success(), "git {args:?} failed");6060 }6161 62+fn cargo_manifest(dir: &Path) {63+    std::fs::write(dir.join("Cargo.toml"), "[package]\nname = \"f0rge\"\nversion = \"0.0.0\"\n").unwrap();64+}65+66+fn write_sh(dir: &Path, body: &str) {67+    let p = dir.join("install.sh");68+    std::fs::write(&p, body).unwrap();69+    #[cfg(unix)]70+    {71+        use std::os::unix::fs::PermissionsExt;72+        std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap();73+    }74+}75+6276 #[test]6377 fn install_dry_run_latest_and_main() {6478     let dir = tmp("dry");6579     assert_eq!(run_in(&dir, &["init"], &[]).2, 0);80+    cargo_manifest(&dir);6681     record(&dir, "v0.1.0", "2026-01-01T00:00:00Z");6782     record(&dir, "v0.1.1", "2026-01-02T00:00:00Z");6883     let home = dir.join("home");6984     let ch = home.join("cargo-home");7085     let dest = ch.join("bin").join("f0rge");7186 72-    // latest = newest record by (created, version): v0.1.1, no artifact/git -> compile dry-run.87+    // latest = newest record by (created, version): v0.1.1, cargo project -> compile dry-run.7388     let (o, err, code) =7489         run_in(&dir, &["release", "install", "--dry-run"], &[("CARGO_HOME", &ch.to_string_lossy())]);7590     assert_eq!((code, err.as_str()), (0, ""), "stdout:\n{o}");@@ -93,6 +108,7 @@ fn install_dry_run_latest_and_main() {93108 fn install_ver_pinned_tag_optional_falls_back() {94109     let dir = tmp("pin");95110     assert_eq!(run_in(&dir, &["init"], &[]).2, 0);111+    cargo_manifest(&dir);96112     record(&dir, "v9.9.9", "2026-01-01T00:00:00Z");97113     git_init(&dir);98114     git(&dir, &["tag", "v9.9.9"]);@@ -121,30 +137,66 @@ fn install_ver_pinned_tag_optional_falls_back() {121137 }122138 123139 #[test]124-fn install_copies_prebuilt_to_cargo_home() {125-    let dir = tmp("copy");140+fn install_sh_handoff_dry_and_real() {141+    // #49: CWD project installer owns placement (stale record sha still uses CWD).142+    let dir = tmp("sh");126143     assert_eq!(run_in(&dir, &["init"], &[]).2, 0);127-    record(&dir, "v1.2.3", "2026-01-01T00:00:00Z");128-    // prebuilt artifact for the resolved ref.129-    let art = dir.join(".forge/releases/build/v1.2.3/f0rge");130-    std::fs::create_dir_all(art.parent().unwrap()).unwrap();131-    std::fs::write(&art, b"fake-f0rge-binary").unwrap();144+    git_init(&dir);145+    let sha = head_short(&dir);146+    record_sha(&dir, "v3.0.0", "2026-01-01T00:00:00Z", &sha);147+    // installer added AFTER the record pin (record sha is stale) + committed.148+    write_sh(&dir, "#!/bin/sh\necho \"$1 $F0RGE_RELEASE_SHA\" > ran.txt\nexit 0\n");149+    git(&dir, &["add", "-A"]);150+    git(&dir, &["commit", "-qm", "installer"]);132151 133-    let ch = dir.join("chome");134-    let dest = ch.join("bin").join("f0rge");135-    // latest == v1.2.3 -> fast path, real copy (no compile).136-    let (o, err, code) =137-        run_in(&dir, &["release", "install"], &[("CARGO_HOME", &ch.to_string_lossy())]);152+    let (o, err, code) = run_in(&dir, &["release", "install", "v3.0.0", "--dry-run"], &[]);138153     assert_eq!((code, err.as_str()), (0, ""), "stdout:\n{o}");139-    assert_eq!(std::fs::read(&dest).unwrap(), b"fake-f0rge-binary");140-    assert!(o.contains("installed") && o.contains("v1.2.3"), "prints path+sha+ref:\n{o}");141-    assert!(o.contains(&dest.display().to_string()), "prints dest path:\n{o}");142-    #[cfg(unix)]143-    {144-        use std::os::unix::fs::PermissionsExt;145-        assert!(std::fs::metadata(&dest).unwrap().permissions().mode() & 0o111 != 0, "chmod +x");146-    }154+    assert!(o.contains("./install.sh") && o.contains("v3.0.0"), "dry exec:\n{o}");155+    assert_eq!(o.lines().count(), 1, "one line:\n{o}");156+    assert!(!dir.join("ran.txt").exists(), "dry-run touches nothing");147157 158+    let (o, err, code) = run_in(&dir, &["release", "install", "v3.0.0"], &[]);159+    assert_eq!((code, err.as_str()), (0, ""), "stdout:\n{o}");160+    assert!(o.contains("./install.sh") && o.contains("v3.0.0"), "ran:\n{o}");161+    let ran = std::fs::read_to_string(dir.join("ran.txt")).unwrap();162+    assert!(ran.contains("v3.0.0"), "script got ver:\n{ran}");163+    assert!(ran.contains(&sha), "script got pin env:\n{ran}");164+165+    let _ = std::fs::remove_dir_all(&dir);166+}167+168+#[test]169+fn install_cargo_only_dry() {170+    // #49: cargo project with no install.sh -> cargo path.171+    let dir = tmp("cargo");172+    assert_eq!(run_in(&dir, &["init"], &[]).2, 0);173+    cargo_manifest(&dir);174+    assert!(!dir.join("install.sh").exists());175+    record(&dir, "v4.0.0", "2026-01-01T00:00:00Z");176+    git_init(&dir);177+    let (o, err, code) = run_in(&dir, &["release", "install", "v4.0.0", "--dry-run"], &[]);178+    assert_eq!((code, err.as_str()), (0, ""), "stdout:\n{o}");179+    assert!(o.contains("cargo install"), "dry cargo:\n{o}");180+    assert_eq!(o.lines().count(), 1, "one line:\n{o}");181+    let _ = std::fs::remove_dir_all(&dir);182+}183+184+#[test]185+fn install_no_installer_errors() {186+    // #49: neither install.sh nor Cargo.toml -> one line + exit 1.187+    let dir = tmp("none");188+    assert_eq!(run_in(&dir, &["init"], &[]).2, 0);189+    assert!(!dir.join("install.sh").exists());190+    assert!(!dir.join("Cargo.toml").exists());191+    record(&dir, "v5.0.0", "2026-01-01T00:00:00Z");192+    git_init(&dir);193+    for args in [&["release", "install", "v5.0.0"][..], &["release", "install", "v5.0.0", "--dry-run"][..]] {194+        let (o, err, code) = run_in(&dir, args, &[]);195+        assert_eq!(code, 1, "args {args:?} stdout:\n{o}\nstderr:\n{err}");196+        assert_eq!(o, "", "stdout empty:\n{o}");197+        assert_eq!(err.lines().count(), 1, "one line:\n{err}");198+        assert!(err.contains("no installer for this project (install.sh or Cargo.toml)"), "stderr:\n{err}");199+    }148200     let _ = std::fs::remove_dir_all(&dir);149201 }150202 @@ -226,6 +278,7 @@ fn install_resolves_record_sha_with_no_tags() {226278     // #47/#48: record sha wins with zero git tags present.227279     let dir = tmp("rsha");228280     assert_eq!(run_in(&dir, &["init"], &[]).2, 0);281+    cargo_manifest(&dir);229282     git_init(&dir);230283     let sha = head_short(&dir);231284     record_sha(&dir, "v0.9.9", "2026-01-01T00:00:00Z", &sha);@@ -239,36 +292,3 @@ fn install_resolves_record_sha_with_no_tags() {239292     assert_eq!(o.lines().count(), 1, "one line:\n{o}");240293     let _ = std::fs::remove_dir_all(&dir);241294 }242-243-#[test]244-fn install_copies_lone_artifact_any_name() {245-    // #48: non-cargo project, one file under build/<ver>/ -> COPY it.246-    let dir = tmp("lone");247-    assert_eq!(run_in(&dir, &["init"], &[]).2, 0);248-    assert!(!dir.join("Cargo.toml").exists());249-    record(&dir, "v2.0.0", "2026-01-01T00:00:00Z");250-    let art = dir.join(".forge/releases/build/v2.0.0/onefile");251-    std::fs::create_dir_all(art.parent().unwrap()).unwrap();252-    std::fs::write(&art, b"#!/bin/sh\necho hi\n").unwrap();253-254-    let ch = dir.join("chome");255-    let dest = ch.join("bin").join("onefile");256-    let (o, err, code) =257-        run_in(&dir, &["release", "install", "v2.0.0", "--dry-run"], &[("CARGO_HOME", &ch.to_string_lossy())]);258-    assert_eq!((code, err.as_str()), (0, ""), "stdout:\n{o}");259-    assert!(o.contains("would copy") && o.contains("onefile"), "dry copy:\n{o}");260-    assert!(o.contains(&dest.display().to_string()), "dry dest:\n{o}");261-    assert!(!dest.exists(), "dry-run touches nothing");262-263-    let (o, err, code) =264-        run_in(&dir, &["release", "install", "v2.0.0"], &[("CARGO_HOME", &ch.to_string_lossy())]);265-    assert_eq!((code, err.as_str()), (0, ""), "stdout:\n{o}");266-    assert_eq!(std::fs::read(&dest).unwrap(), b"#!/bin/sh\necho hi\n");267-    assert!(o.contains("installed") && o.contains("v2.0.0"), "prints path+sha+ref:\n{o}");268-    #[cfg(unix)]269-    {270-        use std::os::unix::fs::PermissionsExt;271-        assert!(std::fs::metadata(&dest).unwrap().permissions().mode() & 0o111 != 0, "chmod +x");272-    }273-    let _ = std::fs::remove_dir_all(&dir);274-}
spec/schema.md+8 −7
diff --git a/spec/schema.md b/spec/schema.mdindex 09dad17..69fa4a8 100644--- a/spec/schema.md+++ b/spec/schema.md@@ -144,13 +144,14 @@ Golden: `spec/fixtures/ci-run.md`.144144   prints the notes body. No delete verb (nothing is deleted).145145 - Install (local machine op; never forge state, never CI):146146   `release install` (latest record), `release install <ver>` (pinned:147-  tag `<ver>` must exist else `no such release: <ver>`, exit 2),148-  `release install main` (current checkout). Fast path: copy prebuilt149-  `releases/build/<ver>/f0rge` to `$CARGO_HOME/bin` (else150-  `~/.cargo/bin`), chmod +x; else `cargo install --locked` from a151-  temp tag checkout (tags) or the current tree (main). Prints152-  `installed <path> <sha256> <ref>`. `--dry-run` prints each step153-  (`would ...`, one line each) without touching the machine.147+  record must exist else `no such release: <ver>`, exit 2),148+  `release install main` (current checkout). Resolve record sha >149+  git tag > current checkout for pin + stage `releases/build/<ver>/`; if150+  CWD repo-root `install.sh` exists run it argv-only as `./install.sh151+  <ver>` (F0RGE_RELEASE_SHA env carries the pin; it owns placement); else if152+  CWD `Cargo.toml` exists `cargo install --locked` from the resolved tree; else `no installer for153+  this project (install.sh or Cargo.toml)` (exit 1). `--dry-run`154+  prints the path it would take (one line) without touching the machine.154155 - Build artifact: CI copies `target/release/f0rge` to155156   `releases/build/<ver>/f0rge` per release record (gitignored,156157   never committed); `status: built|none` still derives from