diff --git a/crates/cli/src/cmd/release.rs b/crates/cli/src/cmd/release.rsindex e073629..f05aadc 100644--- a/crates/cli/src/cmd/release.rs+++ b/crates/cli/src/cmd/release.rs@@ -5,8 +5,8 @@55 //! outputs into releases/out/ (gitignored). No binaries in git.66 77 use crate::{args, output, CliError};8-use f0rge_core::{Forge, ForgeError, Release};9-use serde::Serialize;8+use f0rge_core::{Forge, ForgeError};9+use serde::{Deserialize, Serialize};1010 use std::path::{Component, Path, PathBuf};1111 use std::process::Command;1212 use std::sync::atomic::{AtomicU64, Ordering};@@ -53,6 +53,59 @@ fn clean(s: &str) -> String {5353 s.chars().filter(|c| *c != '\n' && *c != '\r').take(120).collect::<String>().trim().to_string()5454 }5555 56+/// Release record (#47): `sha` is HEAD (short) at create time;57+/// absent on old records and non-git trees. Local mirror of the58+/// core Release + optional sha, so core stays untouched.59+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]60+struct ReleaseRec {61+ version: String,62+ #[serde(default, skip_serializing_if = "Option::is_none")]63+ title: Option<String>,64+ created: String,65+ #[serde(default, skip_serializing_if = "Option::is_none")]66+ sha: Option<String>,67+}68+69+impl f0rge_core::Validate for ReleaseRec {70+ const KNOWN: &'static [&'static str] =71+ &["version", "title", "created", "sha", "schema"];72+ fn validate(&self, file: &str) -> Result<(), ForgeError> {73+ if !f0rge_core::is_rfc3339_utc(&self.created) {74+ return Err(ForgeError::BadValue { file: file.to_string(), field: "created".to_string() });75+ }76+ Ok(())77+ }78+}79+80+/// HEAD short sha via argv-only git; None outside git (#47).81+fn head_sha(repo: &Path) -> Option<String> {82+ let o = Command::new("git")83+ .arg("-C")84+ .arg(repo)85+ .args(["rev-parse", "--short", "HEAD"])86+ .output()87+ .ok()?;88+ if !o.status.success() {89+ return None;90+ }91+ let s = String::from_utf8(o.stdout).ok()?.trim().to_string();92+ if s.is_empty() || s.len() > 64 || !s.chars().all(|c| c.is_ascii_alphanumeric()) {93+ return None;94+ }95+ Some(s)96+}97+98+/// Record sha when it looks like a git sha (else fall through).99+fn good_sha(s: &str) -> bool {100+ !s.is_empty() && s.len() <= 64 && s.chars().all(|c| c.is_ascii_alphanumeric())101+}102+103+fn record_sha(fg: &Forge, ver: &str) -> Option<String> {104+ let p: f0rge_core::Parsed<ReleaseRec> = fg.load_entity(&fg.release_file(ver)).ok()?;105+ let s = p.value.sha?;106+ if good_sha(&s) { Some(s) } else { None }107+}108+56109 /// Version becomes `<ver>.md`; slashes/escapes -> Runtime (exit 1).57110 fn check_ver(raw: &str) -> Result<String, CliError> {58111 let v = clean(raw);@@ -71,6 +124,15 @@ fn check_ver(raw: &str) -> Result<String, CliError> {71124 Ok(v)72125 }73126 127+/// Create enforces the v prefix (#47): bare `0.2.0` -> usage (exit 2).128+fn check_ver_create(raw: &str) -> Result<String, CliError> {129+ let v = check_ver(raw)?;130+ if v.starts_with('v') {131+ return Ok(v);132+ }133+ Err(usage(&format!("versions use the v prefix: v{v}")))134+}135+74136 fn norm_body(b: &str) -> String {75137 if b.is_empty() {76138 return String::new();@@ -218,21 +280,21 @@ fn git(repo: &Path, argv: &[&str]) -> bool {218280 .unwrap_or(false)219281 }220282 221-/// Checkout <ver> tag into a temp worktree; None when no git/no tag.222-fn try_worktree(repo: &Path, ver: &str) -> Option<PathBuf> {223- if !git(repo, &["rev-parse", "--verify", ver]) {283+/// Checkout a tag or sha into a temp worktree; None when no git/no ref.284+fn try_worktree(repo: &Path, gitref: &str, label: &str) -> Option<PathBuf> {285+ if !git(repo, &["rev-parse", "--verify", gitref]) {224286 return None;225287 }226288 let n = BUILD_CTR.fetch_add(1, Ordering::Relaxed);227289 let tmp =228- std::env::temp_dir().join(format!("f0rge-rel-{}-{}-{n}", std::process::id(), ver));290+ std::env::temp_dir().join(format!("f0rge-rel-{}-{}-{n}", std::process::id(), label));229291 let _ = std::fs::remove_dir_all(&tmp);230292 let ok = Command::new("git")231293 .arg("-C")232294 .arg(repo)233295 .args(["worktree", "add", "--detach"])234296 .arg(&tmp)235- .arg(ver)297+ .arg(gitref)236298 .stdout(std::process::Stdio::null())237299 .stderr(std::process::Stdio::null())238300 .status()@@ -289,7 +351,7 @@ fn append_hashes(fg: &Forge, ver: &str) -> Result<(), CliError> {289351 }290352 rows.sort();291353 let rel = fg.release_file(ver);292- let p: f0rge_core::Parsed<Release> = fg.load_entity(&rel).map_err(CliError::from)?;354+ let p: f0rge_core::Parsed<ReleaseRec> = fg.load_entity(&rel).map_err(CliError::from)?;293355 warn(&format!("{ver}.md"), &p.warnings, &p.schema_note);294356 let mut body = p.body;295357 if !body.is_empty() && !body.ends_with('\n') {@@ -310,7 +372,7 @@ fn run_build(fg: &Forge, ver: &str) -> Result<(), CliError> {310372 None => return Ok(()),311373 };312374 let out = repo.join(".forge").join("releases").join("out").join(ver);313- if let Some(wt) = try_worktree(&repo, ver) {375+ if let Some(wt) = try_worktree(&repo, ver, ver) {314376 let scripts = list_scripts(&wt.join(".forge").join("releases").join("build"));315377 let r = if scripts.is_empty() {316378 Ok(())@@ -338,19 +400,22 @@ fn create(fs: &args::FlagSet, fg: &Forge) -> Result<(), CliError> {338400 if fs.positional.len() != 1 {339401 return Err(usage("f0rge release create <ver> --notes <file|->"));340402 }341- let ver = check_ver(&fs.positional[0].clone())?;403+ let ver = check_ver_create(&fs.positional[0].clone())?;342404 let src = fs.get("notes").ok_or_else(|| usage("f0rge release create <ver> --notes <file|->"))?;343405 let notes = read_notes(src)?;344- let rel = Release {406+ let repo = fg.root().parent().map(|p| p.to_path_buf());407+ let sha = repo.as_ref().and_then(|r| head_sha(r));408+ let rel = ReleaseRec {345409 version: ver.clone(),346410 title: fs.get("title").map(|s| clean(s)).filter(|s| !s.is_empty()),347411 created: f0rge_core::now_rfc3339(),412+ sha,348413 };349414 fg.save_entity(&fg.release_file(&ver), &rel, ¬es)?;350415 run_build(fg, &ver)?; // side effect; notes already saved351416 append_hashes(fg, &ver)?; // no-op when out/ is missing or empty352417 let final_body: String = fg353- .load_entity::<Release>(&fg.release_file(&ver))418+ .load_entity::<ReleaseRec>(&fg.release_file(&ver))354419 .map(|p| p.body)355420 .unwrap_or(notes);356421 output::emit_json(@@ -393,7 +458,7 @@ fn load_all(fg: &Forge) -> Vec<Row> {393458 continue;394459 }395460 let rel = Path::new("releases").join(&name);396- match fg.load_entity::<Release>(&rel) {461+ match fg.load_entity::<ReleaseRec>(&rel) {397462 Ok(p) => {398463 warn(&name, &p.warnings, &p.schema_note);399464 out.push(Row { version: ver.to_string(), title: p.value.title, created: p.value.created })@@ -441,7 +506,7 @@ fn list(fs: &args::FlagSet, fg: &Forge) -> Result<(), CliError> {441506 442507 fn view(fs: &args::FlagSet, fg: &Forge, raw: &str) -> Result<(), CliError> {443508 let ver = check_ver(raw)?;444- let p: f0rge_core::Parsed<Release> =509+ let p: f0rge_core::Parsed<ReleaseRec> =445510 fg.load_entity(&fg.release_file(&ver)).map_err(|e| match e {446511 ForgeError::NotFound { .. } => CliError::Runtime(format!("{ver}: not found")),447512 _ => CliError::from(e),@@ -479,15 +544,15 @@ struct InstallJson {479544 method: String,480545 }481546 482-/// Install destination: $CARGO_HOME/bin/f0rge, else ~/.cargo/bin/f0rge.483-fn cargo_dest() -> Result<PathBuf, CliError> {547+/// Install destination: $CARGO_HOME/bin/<name>, else ~/.cargo/bin/<name>.548+fn cargo_dest_for(name: &str) -> Result<PathBuf, CliError> {484549 if let Ok(h) = std::env::var("CARGO_HOME") {485550 if !h.is_empty() {486- return Ok(PathBuf::from(h).join("bin").join("f0rge"));551+ return Ok(PathBuf::from(h).join("bin").join(name));487552 }488553 }489554 match std::env::var("HOME") {490- Ok(h) if !h.is_empty() => Ok(PathBuf::from(h).join(".cargo").join("bin").join("f0rge")),555+ Ok(h) if !h.is_empty() => Ok(PathBuf::from(h).join(".cargo").join("bin").join(name)),491556 _ => Err(CliError::Runtime("no home".to_string())),492557 }493558 }@@ -527,8 +592,84 @@ fn cargo_install_from(src: &Path) -> Result<(), CliError> {527592 }528593 }529594 530-/// Pinned/tag install: fast copy of releases/build/<ver>/f0rge, else531-/// compile from a temp tag checkout (argv-only, worktree cleaned up).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());609+ }610+ if files.len() == 1 { files.into_iter().next() } else { None }611+}612+613+/// Bin name from a Cargo manifest: first [[bin]].name, else614+/// [package].name. Textual scan (no new deps); None when absent.615+fn manifest_bin_name(dir: &Path) -> Option<String> {616+ let text = std::fs::read_to_string(dir.join("Cargo.toml")).ok()?;617+ if text.len() > 65536 {618+ return None;619+ }620+ let mut pkg: Option<String> = None;621+ let mut section = String::new();622+ for line in text.lines() {623+ let t = line.trim();624+ if t.starts_with('[') && t.ends_with(']') {625+ section = t.to_string();626+ continue;627+ }628+ if section != "[package]" && section != "[[bin]]" {629+ continue;630+ }631+ let rest = match t.split_once('=') {632+ Some((k, v)) if k.trim() == "name" => v.trim(),633+ _ => continue,634+ };635+ if !rest.starts_with('"') {636+ continue;637+ }638+ let end = rest[1..].find('"')?;639+ let name = rest[1..1 + end].to_string();640+ if name.is_empty() {641+ continue;642+ }643+ if section == "[[bin]]" {644+ return Some(name);645+ }646+ if pkg.is_none() {647+ pkg = Some(name);648+ }649+ }650+ pkg651+}652+653+/// Path-safe bin name for the cargo fallback; f0rge when the654+/// manifest is missing or the name is not a safe filename (#48).655+fn safe_bin(dir: &Path) -> String {656+ match manifest_bin_name(dir) {657+ Some(n)658+ if !n.is_empty()659+ && n.len() <= 64660+ && n.chars().all(|c| {661+ c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.'662+ }) =>663+ {664+ n665+ }666+ _ => "f0rge".to_string(),667+ }668+}669+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.532673 fn install_ver(533674 fs: &args::FlagSet,534675 fg: &Forge,@@ -536,17 +677,21 @@ fn install_ver(536677 ver: &str,537678 dry: bool,538679 ) -> Result<(), CliError> {539- let dest = cargo_dest()?;540- let artifact = fg.root().join("releases").join("build").join(ver).join("f0rge");541- if artifact.is_file() {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)?;542687 if dry {543- output::out(&format!("would copy {} to {}\n", artifact.display(), dest.display()));688+ output::out(&format!("would copy {} to {}\n", art.display(), dest.display()));544689 return Ok(());545690 }546691 if let Some(p) = dest.parent() {547692 let _ = std::fs::create_dir_all(p);548693 }549- std::fs::copy(&artifact, &dest)694+ std::fs::copy(&art, &dest)550695 .map_err(|_| CliError::Runtime("install failed".to_string()))?;551696 #[cfg(unix)]552697 {@@ -558,24 +703,50 @@ fn install_ver(558703 emit_installed(fs, &dest, &sha, ver, "copy");559704 return Ok(());560705 }706+ let sha = record_sha(fg, ver);707+ let tag_ok = tag_exists(repo, ver);708+ let sha_ok = sha.as_ref().is_some_and(|s| tag_exists(repo, s));709+ let (at, label) = if sha_ok {710+ let s = sha.clone().unwrap_or_default();711+ (Some(s.clone()), format!("{ver} @ {s}"))712+ } else if tag_ok {713+ (Some(ver.to_string()), ver.to_string())714+ } else {715+ (None, format!("{ver} (current checkout)"))716+ };717+ let dest = cargo_dest_for(&safe_bin(repo))?;561718 if dry {562719 output::out(&format!(563- "would run: cargo install --locked --path <checkout of {ver}> to {}\n",720+ "would run: cargo install --locked --path <checkout of {label}> to {}\n",564721 dest.display()565722 ));566723 return Ok(());567724 }568- match try_worktree(repo, ver) {569- Some(wt) => {570- let r = cargo_install_from(&wt);571- cleanup_worktree(repo, &wt);572- r?;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+ }736+ None => {737+ cargo_install_from(repo)?;738+ let sha = sha256_of(&dest).unwrap_or_default();739+ emit_installed(fs, &dest, &sha, ver, "compile");740+ Ok(())741+ }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(())573748 }574- None => cargo_install_from(repo)?,575749 }576- let sha = sha256_of(&dest).unwrap_or_default();577- emit_installed(fs, &dest, &sha, ver, "compile");578- Ok(())579750 }580751 581752 /// Main install: compile the current checkout in place.@@ -586,7 +757,7 @@ fn install_main(586757 dry: bool,587758 ) -> Result<(), CliError> {588759 let _ = fg;589- let dest = cargo_dest()?;760+ let dest = cargo_dest_for(&safe_bin(repo))?;590761 if dry {591762 output::out(&format!(592763 "would run: cargo install --locked --path {} (main) to {}\n",@@ -601,8 +772,8 @@ fn install_main(601772 Ok(())602773 }603774 604-/// `install` = latest record; `install <ver>` = pinned tag (tag must605-/// exist, else usage `no such release: <ver>`); `install main` = cwd.775+/// `install` = latest record; `install <ver>` = pinned record (record776+/// sha > git tag > current checkout); `install main` = cwd.606777 fn install(fs: &args::FlagSet, fg: &Forge, raw: Option<String>) -> Result<(), CliError> {607778 let repo =608779 fg.root().parent().map(|p| p.to_path_buf()).unwrap_or_else(|| PathBuf::from("."));@@ -611,10 +782,7 @@ fn install(fs: &args::FlagSet, fg: &Forge, raw: Option<String>) -> Result<(), Cl611782 Some(r) if clean(&r) == "main" => install_main(fs, fg, &repo, dry),612783 Some(r) => {613784 let ver = check_ver(&r)?;614- if fg.load_entity::<Release>(&fg.release_file(&ver)).is_err() {615- return Err(CliError::Usage(format!("no such release: {ver}")));616- }617- if !tag_exists(&repo, &ver) {785+ if fg.load_entity::<ReleaseRec>(&fg.release_file(&ver)).is_err() {618786 return Err(CliError::Usage(format!("no such release: {ver}")));619787 }620788 install_ver(fs, fg, &repo, &ver, dry)