.forge/prs/f0dcf06d-f1f9-4079-a6fb-2b2bc5f251d8/patch.diff 20.2 KiB 489 lines Raw
1 From 78893d0209aa55f7bff0cef36bbb69c1bd783b69 Mon Sep 17 00:00:00 20012 From: gl0w <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