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