#5 fix-install-place
Patch
.forge/wiki/docs/AGENTS.md+1 −6--- 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+123 −68--- a/crates/cli/src/cmd/release.rs+++ b/crates/cli/src/cmd/release.rs@@ -592,22 +592,50 @@ fn cargo_install_from(src: &Path) -> Result<(), CliError> {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): repo-root `install.sh` run argv-only596+/// as `./install.sh <ver>` owns placement for its ecosystem.597+fn ref_has_file(repo: &Path, gitref: &str, name: &str) -> bool {598+ Command::new("git")599+ .arg("-C")600+ .arg(repo)601+ .args(["cat-file", "-e", &format!("{gitref}:{name}")])602+ .stdout(std::process::Stdio::null())603+ .stderr(std::process::Stdio::null())604+ .status()605+ .map(|s| s.success())606+ .unwrap_or(false)607+}608+609+fn resolved_has(repo: &Path, at: &Option<String>, dir: &Path, name: &str) -> bool {610+ match at {611+ Some(r) => ref_has_file(repo, r, name),612+ None => dir.join(name).is_file(),609613 }610- if files.len() == 1 { files.into_iter().next() } else { None }614+}615+616+/// Run the project installer argv-only, no shell (SEC-04, local posture).617+fn run_install_sh(dir: &Path, ver: &str) -> Result<(), CliError> {618+ let st = Command::new(dir.join("install.sh")).arg(ver).current_dir(dir).status();619+ match st {620+ Ok(s) if s.success() => Ok(()),621+ _ => Err(CliError::Runtime("install failed".to_string())),622+ }623+}624+625+fn emit_script(fs: &args::FlagSet, ver: &str) {626+ if fs.is("json") {627+ output::emit_json(628+ &InstallJson {629+ path: "./install.sh".to_string(),630+ sha256: String::new(),631+ ref_: ver.to_string(),632+ method: "install.sh".to_string(),633+ },634+ fs.get("jq"),635+ );636+ return;637+ }638+ output::out(&format!("ran ./install.sh {ver}\n"));611639 }612640 613641 /// Bin name from a Cargo manifest: first [[bin]].name, else@@ -667,9 +695,10 @@ fn safe_bin(dir: &Path) -> String {667695 }668696 }669697 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.698+/// Pinned install (#49): resolve record sha > git tag > current699+/// checkout, stage releases/build/<ver>/, then hand off: repo-root700+/// install.sh argv-only as ./install.sh <ver>, else cargo install701+/// for cargo projects, else one-line no-installer error. Argv-only.673702 fn install_ver(674703 fs: &args::FlagSet,675704 fg: &Forge,@@ -677,32 +706,6 @@ fn install_ver(677706 ver: &str,678707 dry: bool,679708 ) -> 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- }706709 let sha = record_sha(fg, ver);707710 let tag_ok = tag_exists(repo, ver);708711 let sha_ok = sha.as_ref().is_some_and(|s| tag_exists(repo, s));@@ -714,42 +717,80 @@ fn install_ver(714717 } else {715718 (None, format!("{ver} (current checkout)"))716719 };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");720+ let has_sh = resolved_has(repo, &at, repo, "install.sh");721+ let has_cargo = resolved_has(repo, &at, repo, "Cargo.toml");722+ if has_sh {723+ if dry {724+ output::out(&format!("would run: ./install.sh {ver} (checkout of {label})\n"));725+ return Ok(());726+ }727+ let build_dir = fg.root().join("releases").join("build").join(ver);728+ let _ = std::fs::create_dir_all(&build_dir);729+ match at {730+ Some(r) => match try_worktree(repo, &r, ver) {731+ Some(wt) => {732+ let res = run_install_sh(&wt, ver);733+ cleanup_worktree(repo, &wt);734+ res?;735+ emit_script(fs, ver);736+ Ok(())737+ }738+ None => {739+ run_install_sh(repo, ver)?;740+ emit_script(fs, ver);741+ Ok(())742+ }743+ },744+ None => {745+ run_install_sh(repo, ver)?;746+ emit_script(fs, ver);734747 Ok(())735748 }749+ }750+ } else if has_cargo {751+ let dest = cargo_dest_for(&safe_bin(repo))?;752+ if dry {753+ output::out(&format!(754+ "would run: cargo install --locked --path <checkout of {label}> to {}\n",755+ dest.display()756+ ));757+ return Ok(());758+ }759+ let build_dir = fg.root().join("releases").join("build").join(ver);760+ let _ = std::fs::create_dir_all(&build_dir);761+ match at {762+ Some(r) => match try_worktree(repo, &r, ver) {763+ Some(wt) => {764+ let res = cargo_install_from(&wt);765+ let dest = cargo_dest_for(&safe_bin(&wt)).unwrap_or(dest);766+ cleanup_worktree(repo, &wt);767+ res?;768+ let sha = sha256_of(&dest).unwrap_or_default();769+ emit_installed(fs, &dest, &sha, ver, "compile");770+ Ok(())771+ }772+ None => {773+ cargo_install_from(repo)?;774+ let sha = sha256_of(&dest).unwrap_or_default();775+ emit_installed(fs, &dest, &sha, ver, "compile");776+ Ok(())777+ }778+ },736779 None => {737780 cargo_install_from(repo)?;738781 let sha = sha256_of(&dest).unwrap_or_default();739782 emit_installed(fs, &dest, &sha, ver, "compile");740783 Ok(())741784 }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(())748785 }786+ } else {787+ Err(CliError::Runtime(788+ "no installer for this project (install.sh or Cargo.toml)".to_string(),789+ ))749790 }750791 }751792 752-/// Main install: compile the current checkout in place.793+/// Main install: current checkout hands to install.sh or cargo.753794 fn install_main(754795 fs: &args::FlagSet,755796 fg: &Forge,@@ -757,6 +798,20 @@ fn install_main(757798 dry: bool,758799 ) -> Result<(), CliError> {759800 let _ = fg;801+ if repo.join("install.sh").is_file() {802+ if dry {803+ output::out("would run: ./install.sh main\n");804+ return Ok(());805+ }806+ run_install_sh(repo, "main")?;807+ emit_script(fs, "main");808+ return Ok(());809+ }810+ if !repo.join("Cargo.toml").is_file() {811+ return Err(CliError::Runtime(812+ "no installer for this project (install.sh or Cargo.toml)".to_string(),813+ ));814+ }760815 let dest = cargo_dest_for(&safe_bin(repo))?;761816 if dry {762817 output::out(&format!(
crates/cli/tests/install.rs+77 −55--- 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,68 @@ 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: project installer owns placement.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+ write_sh(&dir, "#!/bin/sh\necho \"$1\" > ran.txt\necho \"$1 $PWD\" >> ran.txt\nexit 0\n");145+ record(&dir, "v3.0.0", "2026-01-01T00:00:00Z");146+ git_init(&dir);147+ git(&dir, &["tag", "v3.0.0"]);132148 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())]);149+ let (o, err, code) = run_in(&dir, &["release", "install", "v3.0.0", "--dry-run"], &[]);138150 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- }151+ assert!(o.contains("./install.sh") && o.contains("v3.0.0"), "dry exec:\n{o}");152+ assert_eq!(o.lines().count(), 1, "one line:\n{o}");153+ assert!(!dir.join("ran.txt").exists(), "dry-run touches nothing");154+155+ let (o, err, code) = run_in(&dir, &["release", "install", "v3.0.0"], &[]);156+ assert_eq!((code, err.as_str()), (0, ""), "stdout:\n{o}");157+ assert!(o.contains("./install.sh") && o.contains("v3.0.0"), "ran:\n{o}");158+ // script runs in a worktree for tags; check both checkout and worktree left no trace except local run marker.159+ // For current-checkout fallback the file lands in dir; for tag worktree it lands there (cleaned).160+ // Force a no-tag record to prove the local write path:161+ record(&dir, "v3.0.1", "2026-01-02T00:00:00Z");162+ let (o, err, code) = run_in(&dir, &["release", "install", "v3.0.1"], &[]);163+ assert_eq!((code, err.as_str()), (0, ""), "stdout:\n{o}");164+ let ran = std::fs::read_to_string(dir.join("ran.txt")).unwrap();165+ assert!(ran.contains("v3.0.1"), "script got ver:\n{ran}");166+167+ let _ = std::fs::remove_dir_all(&dir);168+}169+170+#[test]171+fn install_cargo_only_dry() {172+ // #49: cargo project with no install.sh -> cargo path.173+ let dir = tmp("cargo");174+ assert_eq!(run_in(&dir, &["init"], &[]).2, 0);175+ cargo_manifest(&dir);176+ assert!(!dir.join("install.sh").exists());177+ record(&dir, "v4.0.0", "2026-01-01T00:00:00Z");178+ git_init(&dir);179+ let (o, err, code) = run_in(&dir, &["release", "install", "v4.0.0", "--dry-run"], &[]);180+ assert_eq!((code, err.as_str()), (0, ""), "stdout:\n{o}");181+ assert!(o.contains("cargo install"), "dry cargo:\n{o}");182+ assert_eq!(o.lines().count(), 1, "one line:\n{o}");183+ let _ = std::fs::remove_dir_all(&dir);184+}147185 186+#[test]187+fn install_no_installer_errors() {188+ // #49: neither install.sh nor Cargo.toml -> one line + exit 1.189+ let dir = tmp("none");190+ assert_eq!(run_in(&dir, &["init"], &[]).2, 0);191+ assert!(!dir.join("install.sh").exists());192+ assert!(!dir.join("Cargo.toml").exists());193+ record(&dir, "v5.0.0", "2026-01-01T00:00:00Z");194+ git_init(&dir);195+ for args in [&["release", "install", "v5.0.0"][..], &["release", "install", "v5.0.0", "--dry-run"][..]] {196+ let (o, err, code) = run_in(&dir, args, &[]);197+ assert_eq!(code, 1, "args {args:?} stdout:\n{o}\nstderr:\n{err}");198+ assert_eq!(o, "", "stdout empty:\n{o}");199+ assert_eq!(err.lines().count(), 1, "one line:\n{err}");200+ assert!(err.contains("no installer for this project (install.sh or Cargo.toml)"), "stderr:\n{err}");201+ }148202 let _ = std::fs::remove_dir_all(&dir);149203 }150204 @@ -226,6 +280,7 @@ fn install_resolves_record_sha_with_no_tags() {226280 // #47/#48: record sha wins with zero git tags present.227281 let dir = tmp("rsha");228282 assert_eq!(run_in(&dir, &["init"], &[]).2, 0);283+ cargo_manifest(&dir);229284 git_init(&dir);230285 let sha = head_short(&dir);231286 record_sha(&dir, "v0.9.9", "2026-01-01T00:00:00Z", &sha);@@ -239,36 +294,3 @@ fn install_resolves_record_sha_with_no_tags() {239294 assert_eq!(o.lines().count(), 1, "one line:\n{o}");240295 let _ = std::fs::remove_dir_all(&dir);241296 }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 −8--- 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; stage `releases/build/<ver>/`; if150+ repo-root `install.sh` exists run it argv-only as `./install.sh151+ <ver>` (it owns placement); else if `Cargo.toml` exists `cargo152+ 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 from157--
Comments (0)
No comments yet.
Reviews (1)
-
unknown · · commented
closing: superseded. install.sh placement landed via #47-#49 on main; this branch would reintroduce the old layout.