#5 fix-install-place

closedopened
details
branches
main ← fix-install-place
issues
97eeb419-d05e-49b2-b23a-e1ccef967219
updated
id
f0dcf06d-f1f9-4079-a6fb-2b2bc5f251d8

Patch

.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+123 −68
diff --git a/crates/cli/src/cmd/release.rs b/crates/cli/src/cmd/release.rsindex f05aadc..0f3383b 100644--- 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
diff --git a/crates/cli/tests/install.rs b/crates/cli/tests/install.rsindex 264ce83..58cb000 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,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
diff --git a/spec/schema.md b/spec/schema.mdindex 09dad17..f094244 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; 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-- 2.47.3

Comments (0)

No comments yet.

Reviews (1)

all pulls