1 #![forbid(unsafe_code)]2 //! f0rge release: notes are truth, build is a side effect.3 //! Verbs: create <ver> --notes <file|->, list, view <ver>.4 //! Build: tag checkout in temp worktree, scripts run argv-only,5 //! outputs into releases/out/ (gitignored). No binaries in git.6 7 use crate::{args, output, CliError};8 use f0rge_core::{Forge, ForgeError};9 use serde::{Deserialize, Serialize};10 use std::path::{Component, Path, PathBuf};11 use std::process::Command;12 use std::sync::atomic::{AtomicU64, Ordering};13 14 pub fn help() -> &'static str {15 "f0rge release: versions\nusage: f0rge release <create|list|view|install> [ver|main] [--json|--dry-run]\nnotes: .forge/releases/<ver>.md\ncreate: --notes <file|-> [--title S]; install [--dry-run]\ndocs: spec/schema.md §Release\ncontract: f0rge --skill\n"16 }17 18 fn spec() -> Vec<args::FlagSpec> {19 args::spec_from_lists(&["notes", "title", "jq"], &["json", "dry-run"])20 }21 22 fn usage(msg: &str) -> CliError {23 CliError::Usage(msg.to_string())24 }25 26 // Per-verb helps next to parser arms (#36). Each <=250B/8ln.27 const CREATE_H: &str = "f0rge release create: notes\nusage: f0rge release create <ver> --notes <file|-> [--title S]\ncontract: f0rge --skill\n";28 const LIST_H: &str = "f0rge release list: all\nusage: f0rge release list\ncontract: f0rge --skill\n";29 const VIEW_H: &str = "f0rge release view: notes\nusage: f0rge release view <ver>\ncontract: f0rge --skill\n";30 const INSTALL_H: &str = "f0rge release install: setup\nusage: f0rge release install [ver|main] [--dry-run]\ncontract: f0rge --skill\n";31 32 /// Verb-depth help (#36). None when unknown.33 pub fn verb_help(verb: &str) -> Option<&'static str> {34 Some(match verb {35 "create" => CREATE_H,36 "list" => LIST_H,37 "view" => VIEW_H,38 "install" => INSTALL_H,39 _ => return None,40 })41 }42 43 fn with_try(e: CliError, verb: &str) -> CliError {44 match e {45 CliError::Usage(m) if !m.contains("(try:") => {46 CliError::Usage(format!("{m} (try: f0rge release {verb} --help)"))47 }48 other => other,49 }50 }51 52 fn clean(s: &str) -> String {53 s.chars().filter(|c| *c != '\n' && *c != '\r').take(120).collect::<String>().trim().to_string()54 }55 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 109 /// Version becomes `<ver>.md`; slashes/escapes -> Runtime (exit 1).110 fn check_ver(raw: &str) -> Result<String, CliError> {111 let v = clean(raw);112 if v.is_empty() || v == "." || v == ".." {113 return Err(usage("f0rge release <create|list|view> [ver]"));114 }115 for c in Path::new(&v).components() {116 match c {117 Component::Normal(_) => {}118 _ => return Err(CliError::Runtime(format!("{v}: escapes releases"))),119 }120 }121 if v.contains('\\') {122 return Err(CliError::Runtime(format!("{v}: escapes releases")));123 }124 Ok(v)125 }126 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 136 fn norm_body(b: &str) -> String {137 if b.is_empty() {138 return String::new();139 }140 if b.ends_with('\n') {141 b.to_string()142 } else {143 format!("{b}\n")144 }145 }146 147 /// Notes from `-` (stdin) or a local file; capped at SEC-01 limit.148 fn read_notes(src: &str) -> Result<String, CliError> {149 let bytes: Vec<u8> = if src == "-" {150 use std::io::Read;151 let mut buf = Vec::new();152 std::io::stdin()153 .lock()154 .take(f0rge_core::MAX_FILE_BYTES as u64 + 1)155 .read_to_end(&mut buf)156 .map_err(|_| CliError::Runtime("stdin: io error".to_string()))?;157 buf158 } else {159 std::fs::read(Path::new(src)).map_err(|_| {160 CliError::Runtime(format!("{}: not found", base_of(src)))161 })?162 };163 if bytes.len() > f0rge_core::MAX_FILE_BYTES {164 return Err(CliError::Runtime(format!("{}: file over 256 KiB cap", base_of(src))));165 }166 String::from_utf8(bytes)167 .map_err(|_| CliError::Runtime(format!("{}: not utf-8", base_of(src))))168 .map(|s| norm_body(&s))169 }170 171 fn base_of(p: &str) -> String {172 Path::new(p)173 .file_name()174 .map(|s| s.to_string_lossy().into_owned())175 .unwrap_or_else(|| clean(p))176 }177 178 /// Unknown-field warnings + schema note, one line each on stderr.179 fn warn(file: &str, w: &[String], note: &Option<String>) {180 for x in w {181 eprintln!("warning: {file}: {x}");182 }183 output::warn_schema(note);184 }185 186 #[derive(Debug, Clone, Serialize)]187 struct ReleaseJson {188 version: String,189 title: Option<String>,190 created: String,191 body: String,192 status: String,193 }194 195 #[derive(Debug, Clone, Serialize)]196 struct ListJson {197 version: String,198 title: Option<String>,199 created: String,200 status: String,201 }202 203 /// Derived build status: artifacts under out/<ver>/ -> built, else none.204 fn build_status(fg: &Forge, ver: &str) -> String {205 let dir = fg.root().join("releases").join("out").join(ver);206 match std::fs::read_dir(&dir) {207 Ok(mut rd) => {208 if rd.next().is_some() {209 "built".to_string()210 } else {211 "none".to_string()212 }213 }214 Err(_) => "none".to_string(),215 }216 }217 218 // ---- build side effect ----219 220 static BUILD_CTR: AtomicU64 = AtomicU64::new(0);221 222 /// Executable scripts in dir, filename order. Symlinks skipped (SEC-02).223 fn list_scripts(dir: &Path) -> Vec<PathBuf> {224 let rd = match std::fs::read_dir(dir) {225 Ok(r) => r,226 Err(_) => return Vec::new(),227 };228 let mut out = Vec::new();229 for e in rd.flatten() {230 let ft = match e.file_type() {231 Ok(t) => t,232 Err(_) => continue,233 };234 if ft.is_symlink() || !ft.is_file() {235 continue;236 }237 out.push(e.path());238 }239 out.sort();240 out241 }242 243 #[cfg(unix)]244 fn is_executable(p: &Path) -> bool {245 use std::os::unix::fs::PermissionsExt;246 std::fs::metadata(p).map(|m| m.permissions().mode() & 0o111 != 0).unwrap_or(false)247 }248 249 #[cfg(not(unix))]250 fn is_executable(_p: &Path) -> bool {251 true252 }253 254 /// Run scripts with cwd=work, env VERSION/OUT (argv only, SEC-04).255 /// First nonzero exit -> one-line error; notes already saved.256 fn run_each(scripts: &[PathBuf], work: &Path, ver: &str, out: &Path) -> Result<(), CliError> {257 for s in scripts {258 let output = if is_executable(s) {259 Command::new(s).current_dir(work).env("VERSION", ver).env("OUT", out).output()260 } else {261 Command::new("sh").arg(s).current_dir(work).env("VERSION", ver).env("OUT", out).output()262 };263 match output {264 Ok(o) if o.status.success() => {}265 _ => return Err(CliError::Runtime(format!("release {ver}: build failed"))),266 }267 }268 Ok(())269 }270 271 fn git(repo: &Path, argv: &[&str]) -> bool {272 Command::new("git")273 .arg("-C")274 .arg(repo)275 .args(argv)276 .stdout(std::process::Stdio::null())277 .stderr(std::process::Stdio::null())278 .status()279 .map(|s| s.success())280 .unwrap_or(false)281 }282 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]) {286 return None;287 }288 let n = BUILD_CTR.fetch_add(1, Ordering::Relaxed);289 let tmp =290 std::env::temp_dir().join(format!("f0rge-rel-{}-{}-{n}", std::process::id(), label));291 let _ = std::fs::remove_dir_all(&tmp);292 let ok = Command::new("git")293 .arg("-C")294 .arg(repo)295 .args(["worktree", "add", "--detach"])296 .arg(&tmp)297 .arg(gitref)298 .stdout(std::process::Stdio::null())299 .stderr(std::process::Stdio::null())300 .status()301 .map(|s| s.success())302 .unwrap_or(false);303 if ok {304 Some(tmp)305 } else {306 let _ = std::fs::remove_dir_all(&tmp);307 None308 }309 }310 311 fn cleanup_worktree(repo: &Path, wt: &Path) {312 git(repo, &["worktree", "remove", "--force", &wt.to_string_lossy()]);313 let _ = std::fs::remove_dir_all(wt);314 }315 316 /// sha256 of one file via the system tool (argv-only, SEC-04).317 /// None when the tool is missing or the file unreadable (block skips it).318 fn sha256_of(p: &Path) -> Option<String> {319 let o = Command::new("sha256sum").arg(p).output().ok()?;320 if !o.status.success() {321 return None;322 }323 String::from_utf8(o.stdout).ok()?.split_whitespace().next().map(|s| s.to_string())324 }325 326 /// M8: append a `## sha256` block for artifacts in `out/<ver>/` onto327 /// `releases/<ver>.md` (body only; frontmatter untouched). Empty-safe:328 /// missing/empty out dir, or no hashable files, is a no-op.329 fn append_hashes(fg: &Forge, ver: &str) -> Result<(), CliError> {330 let dir = fg.root().join("releases").join("out").join(ver);331 let rd = match std::fs::read_dir(&dir) {332 Ok(r) => r,333 Err(_) => return Ok(()),334 };335 let mut rows: Vec<(String, String)> = Vec::new();336 for e in rd.flatten() {337 let ft = match e.file_type() {338 Ok(t) => t,339 Err(_) => continue,340 };341 if ft.is_symlink() || !ft.is_file() {342 continue;343 }344 let name = e.file_name().to_string_lossy().into_owned();345 if let Some(h) = sha256_of(&e.path()) {346 rows.push((name, h));347 }348 }349 if rows.is_empty() {350 return Ok(());351 }352 rows.sort();353 let rel = fg.release_file(ver);354 let p: f0rge_core::Parsed<ReleaseRec> = fg.load_entity(&rel).map_err(CliError::from)?;355 warn(&format!("{ver}.md"), &p.warnings, &p.schema_note);356 let mut body = p.body;357 if !body.is_empty() && !body.ends_with('\n') {358 body.push('\n');359 }360 body.push_str("\n## sha256\n");361 for (name, h) in &rows {362 body.push_str(&format!("{h} {name}\n"));363 }364 fg.save_entity(&rel, &p.value, &body).map_err(CliError::from)?;365 Ok(())366 }367 368 /// Notes are truth: returns Ok unless a build script fails (exit 1).369 fn run_build(fg: &Forge, ver: &str) -> Result<(), CliError> {370 let repo = match fg.root().parent() {371 Some(p) => p.to_path_buf(),372 None => return Ok(()),373 };374 let out = repo.join(".forge").join("releases").join("out").join(ver);375 if let Some(wt) = try_worktree(&repo, ver, ver) {376 let scripts = list_scripts(&wt.join(".forge").join("releases").join("build"));377 let r = if scripts.is_empty() {378 Ok(())379 } else {380 std::fs::create_dir_all(&out)381 .map_err(|_| CliError::Runtime(format!("release {ver}: build failed")))?;382 run_each(&scripts, &wt, ver, &out)383 };384 cleanup_worktree(&repo, &wt);385 return r;386 }387 // No git/tag here: run current-tree scripts against the checkout.388 let scripts = list_scripts(&repo.join(".forge").join("releases").join("build"));389 if scripts.is_empty() {390 return Ok(());391 }392 std::fs::create_dir_all(&out)393 .map_err(|_| CliError::Runtime(format!("release {ver}: build failed")))?;394 run_each(&scripts, &repo, ver, &out)395 }396 397 // ---- verbs ----398 399 fn create(fs: &args::FlagSet, fg: &Forge) -> Result<(), CliError> {400 if fs.positional.len() != 1 {401 return Err(usage("f0rge release create <ver> --notes <file|->"));402 }403 let ver = check_ver_create(&fs.positional[0].clone())?;404 let src = fs.get("notes").ok_or_else(|| usage("f0rge release create <ver> --notes <file|->"))?;405 let notes = read_notes(src)?;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 {409 version: ver.clone(),410 title: fs.get("title").map(|s| clean(s)).filter(|s| !s.is_empty()),411 created: f0rge_core::now_rfc3339(),412 sha,413 };414 fg.save_entity(&fg.release_file(&ver), &rel, ¬es)?;415 run_build(fg, &ver)?; // side effect; notes already saved416 append_hashes(fg, &ver)?; // no-op when out/ is missing or empty417 let final_body: String = fg418 .load_entity::<ReleaseRec>(&fg.release_file(&ver))419 .map(|p| p.body)420 .unwrap_or(notes);421 output::emit_json(422 &ReleaseJson {423 version: ver.clone(),424 title: rel.title,425 created: rel.created,426 body: final_body,427 status: build_status(fg, &ver),428 },429 fs.get("jq"),430 );431 Ok(())432 }433 434 struct Row {435 version: String,436 title: Option<String>,437 created: String,438 }439 440 fn load_all(fg: &Forge) -> Vec<Row> {441 let dir = fg.root().join("releases");442 let rd = match std::fs::read_dir(&dir) {443 Ok(r) => r,444 Err(_) => return Vec::new(),445 };446 let mut out = Vec::new();447 for e in rd.flatten() {448 let ft = match e.file_type() {449 Ok(t) => t,450 Err(_) => continue,451 };452 if ft.is_symlink() || !ft.is_file() {453 continue;454 }455 let name = e.file_name().to_string_lossy().into_owned();456 let Some(ver) = name.strip_suffix(".md") else { continue };457 if check_ver(ver).is_err() {458 continue;459 }460 let rel = Path::new("releases").join(&name);461 match fg.load_entity::<ReleaseRec>(&rel) {462 Ok(p) => {463 warn(&name, &p.warnings, &p.schema_note);464 out.push(Row { version: ver.to_string(), title: p.value.title, created: p.value.created })465 }466 Err(_) => continue,467 }468 }469 out.sort_by(|a, b| (a.created.clone(), a.version.clone()).cmp(&(b.created.clone(), b.version.clone())));470 out471 }472 473 fn list(fs: &args::FlagSet, fg: &Forge) -> Result<(), CliError> {474 if !fs.positional.is_empty() {475 return Err(usage("f0rge release list"));476 }477 let rows = load_all(fg);478 if fs.is("json") {479 if rows.is_empty() {480 output::emit_empty_array(fs.get("jq"));481 return Ok(());482 }483 let js: Vec<ListJson> = rows484 .into_iter()485 .map(|r| {486 let st = build_status(fg, &r.version);487 ListJson { version: r.version, title: r.title, created: r.created, status: st }488 })489 .collect();490 output::emit_json(&js, fs.get("jq"));491 return Ok(());492 }493 if rows.is_empty() {494 return Ok(());495 }496 let grid: Vec<Vec<String>> = rows497 .into_iter()498 .map(|r| {499 let st = build_status(fg, &r.version);500 vec![r.version, r.created, st]501 })502 .collect();503 output::out(&format!("{}\n", output::align(&grid)));504 Ok(())505 }506 507 fn view(fs: &args::FlagSet, fg: &Forge, raw: &str) -> Result<(), CliError> {508 let ver = check_ver(raw)?;509 let p: f0rge_core::Parsed<ReleaseRec> =510 fg.load_entity(&fg.release_file(&ver)).map_err(|e| match e {511 ForgeError::NotFound { .. } => CliError::Runtime(format!("{ver}: not found")),512 _ => CliError::from(e),513 })?;514 warn(&format!("{ver}.md"), &p.warnings, &p.schema_note);515 if fs.is("json") {516 output::emit_json(517 &ReleaseJson {518 version: ver.clone(),519 title: p.value.title,520 created: p.value.created,521 body: p.body.clone(),522 status: build_status(fg, &ver),523 },524 fs.get("jq"),525 );526 return Ok(());527 }528 let mut body = p.body.clone();529 if !body.is_empty() && !body.ends_with('\n') {530 body.push('\n');531 }532 output::out(&body);533 Ok(())534 }535 536 // ---- install (local machine op; never forge state, never CI) ----537 538 #[derive(Debug, Clone, Serialize)]539 struct InstallJson {540 path: String,541 sha256: String,542 #[serde(rename = "ref")]543 ref_: String,544 method: String,545 }546 547 /// Install destination: $CARGO_HOME/bin/<name>, else ~/.cargo/bin/<name>.548 fn cargo_dest_for(name: &str) -> Result<PathBuf, CliError> {549 if let Ok(h) = std::env::var("CARGO_HOME") {550 if !h.is_empty() {551 return Ok(PathBuf::from(h).join("bin").join(name));552 }553 }554 match std::env::var("HOME") {555 Ok(h) if !h.is_empty() => Ok(PathBuf::from(h).join(".cargo").join("bin").join(name)),556 _ => Err(CliError::Runtime("no home".to_string())),557 }558 }559 560 fn tag_exists(repo: &Path, tag: &str) -> bool {561 git(repo, &["rev-parse", "--verify", tag])562 }563 564 fn emit_installed(fs: &args::FlagSet, dest: &Path, sha: &str, rf: &str, method: &str) {565 if fs.is("json") {566 output::emit_json(567 &InstallJson {568 path: dest.display().to_string(),569 sha256: sha.to_string(),570 ref_: rf.to_string(),571 method: method.to_string(),572 },573 fs.get("jq"),574 );575 return;576 }577 output::out(&format!("installed {} {} {}\n", dest.display(), sha, rf));578 }579 580 fn cargo_install_from(src: &Path) -> Result<(), CliError> {581 let st = Command::new("cargo")582 .arg("install")583 .arg("--locked")584 .arg("--path")585 .arg(src)586 .stdout(std::process::Stdio::null())587 .stderr(std::process::Stdio::null())588 .status();589 match st {590 Ok(s) if s.success() => Ok(()),591 _ => Err(CliError::Runtime("install failed".to_string())),592 }593 }594 595 /// Installer convention (#49): CWD repo-root `install.sh` run argv-only596 /// as `./install.sh <ver>` owns placement for its ecosystem.597 /// Detection + exec are CWD-based; record-sha/tag pin only the598 /// version (and cargo source tree / staging), never the installer.599 /// Run the project installer argv-only, no shell (SEC-04, local posture).600 fn run_install_sh(dir: &Path, ver: &str, env_sha: &str) -> Result<(), CliError> {601 let st = Command::new(dir.join("install.sh"))602 .arg(ver)603 .current_dir(dir)604 .env("F0RGE_RELEASE_SHA", env_sha)605 .status();606 match st {607 Ok(s) if s.success() => Ok(()),608 _ => Err(CliError::Runtime("install failed".to_string())),609 }610 }611 612 fn emit_script(fs: &args::FlagSet, ver: &str) {613 if fs.is("json") {614 output::emit_json(615 &InstallJson {616 path: "./install.sh".to_string(),617 sha256: String::new(),618 ref_: ver.to_string(),619 method: "install.sh".to_string(),620 },621 fs.get("jq"),622 );623 return;624 }625 output::out(&format!("ran ./install.sh {ver}\n"));626 }627 628 /// Bin name from a Cargo manifest: first [[bin]].name, else629 /// [package].name. Textual scan (no new deps); None when absent.630 fn manifest_bin_name(dir: &Path) -> Option<String> {631 let text = std::fs::read_to_string(dir.join("Cargo.toml")).ok()?;632 if text.len() > 65536 {633 return None;634 }635 let mut pkg: Option<String> = None;636 let mut section = String::new();637 for line in text.lines() {638 let t = line.trim();639 if t.starts_with('[') && t.ends_with(']') {640 section = t.to_string();641 continue;642 }643 if section != "[package]" && section != "[[bin]]" {644 continue;645 }646 let rest = match t.split_once('=') {647 Some((k, v)) if k.trim() == "name" => v.trim(),648 _ => continue,649 };650 if !rest.starts_with('"') {651 continue;652 }653 let end = rest[1..].find('"')?;654 let name = rest[1..1 + end].to_string();655 if name.is_empty() {656 continue;657 }658 if section == "[[bin]]" {659 return Some(name);660 }661 if pkg.is_none() {662 pkg = Some(name);663 }664 }665 pkg666 }667 668 /// Path-safe bin name for the cargo fallback; f0rge when the669 /// manifest is missing or the name is not a safe filename (#48).670 fn safe_bin(dir: &Path) -> String {671 match manifest_bin_name(dir) {672 Some(n)673 if !n.is_empty()674 && n.len() <= 64675 && n.chars().all(|c| {676 c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.'677 }) =>678 {679 n680 }681 _ => "f0rge".to_string(),682 }683 }684 685 /// Pinned install (#49): resolve record sha > git tag > current686 /// checkout for pin + stage releases/build/<ver>/, then hand off687 /// CWD-based: CWD ./install.sh argv-only as ./install.sh <ver>688 /// (F0RGE_RELEASE_SHA env carries the pin), else cargo install689 /// from the resolved tree for CWD cargo projects, else one-line690 /// no-installer error. Argv-only.691 fn install_ver(692 fs: &args::FlagSet,693 fg: &Forge,694 repo: &Path,695 ver: &str,696 dry: bool,697 ) -> Result<(), CliError> {698 let sha = record_sha(fg, ver);699 let tag_ok = tag_exists(repo, ver);700 let sha_ok = sha.as_ref().is_some_and(|s| tag_exists(repo, s));701 let (at, label) = if sha_ok {702 let s = sha.clone().unwrap_or_default();703 (Some(s.clone()), format!("{ver} @ {s}"))704 } else if tag_ok {705 (Some(ver.to_string()), ver.to_string())706 } else {707 (None, format!("{ver} (current checkout)"))708 };709 let has_sh = repo.join("install.sh").is_file();710 let has_cargo = repo.join("Cargo.toml").is_file();711 if has_sh {712 if dry {713 output::out(&format!("would run: ./install.sh {ver} (checkout of {label})\n"));714 return Ok(());715 }716 let build_dir = fg.root().join("releases").join("build").join(ver);717 let _ = std::fs::create_dir_all(&build_dir);718 let env_sha = sha.clone().or(at.clone()).unwrap_or_default();719 run_install_sh(repo, ver, &env_sha)?;720 emit_script(fs, ver);721 Ok(())722 } else if has_cargo {723 let dest = cargo_dest_for(&safe_bin(repo))?;724 if dry {725 output::out(&format!(726 "would run: cargo install --locked --path <checkout of {label}> to {}\n",727 dest.display()728 ));729 return Ok(());730 }731 let build_dir = fg.root().join("releases").join("build").join(ver);732 let _ = std::fs::create_dir_all(&build_dir);733 match at {734 Some(r) => match try_worktree(repo, &r, ver) {735 Some(wt) => {736 let res = cargo_install_from(&wt);737 let dest = cargo_dest_for(&safe_bin(&wt)).unwrap_or(dest);738 cleanup_worktree(repo, &wt);739 res?;740 let sha = sha256_of(&dest).unwrap_or_default();741 emit_installed(fs, &dest, &sha, ver, "compile");742 Ok(())743 }744 None => {745 cargo_install_from(repo)?;746 let sha = sha256_of(&dest).unwrap_or_default();747 emit_installed(fs, &dest, &sha, ver, "compile");748 Ok(())749 }750 },751 None => {752 cargo_install_from(repo)?;753 let sha = sha256_of(&dest).unwrap_or_default();754 emit_installed(fs, &dest, &sha, ver, "compile");755 Ok(())756 }757 }758 } else {759 Err(CliError::Runtime(760 "no installer for this project (install.sh or Cargo.toml)".to_string(),761 ))762 }763 }764 765 /// Main install: current checkout hands to install.sh or cargo.766 fn install_main(767 fs: &args::FlagSet,768 fg: &Forge,769 repo: &Path,770 dry: bool,771 ) -> Result<(), CliError> {772 let _ = fg;773 if repo.join("install.sh").is_file() {774 if dry {775 output::out("would run: ./install.sh main\n");776 return Ok(());777 }778 run_install_sh(repo, "main", "main")?;779 emit_script(fs, "main");780 return Ok(());781 }782 if !repo.join("Cargo.toml").is_file() {783 return Err(CliError::Runtime(784 "no installer for this project (install.sh or Cargo.toml)".to_string(),785 ));786 }787 let dest = cargo_dest_for(&safe_bin(repo))?;788 if dry {789 output::out(&format!(790 "would run: cargo install --locked --path {} (main) to {}\n",791 repo.display(),792 dest.display()793 ));794 return Ok(());795 }796 cargo_install_from(repo)?;797 let sha = sha256_of(&dest).unwrap_or_default();798 emit_installed(fs, &dest, &sha, "main", "compile");799 Ok(())800 }801 802 /// `install` = latest record; `install <ver>` = pinned record (record803 /// sha > git tag > current checkout); `install main` = cwd.804 fn install(fs: &args::FlagSet, fg: &Forge, raw: Option<String>) -> Result<(), CliError> {805 let repo =806 fg.root().parent().map(|p| p.to_path_buf()).unwrap_or_else(|| PathBuf::from("."));807 let dry = fs.is("dry-run");808 match raw {809 Some(r) if clean(&r) == "main" => install_main(fs, fg, &repo, dry),810 Some(r) => {811 let ver = check_ver(&r)?;812 if fg.load_entity::<ReleaseRec>(&fg.release_file(&ver)).is_err() {813 return Err(CliError::Usage(format!("no such release: {ver}")));814 }815 install_ver(fs, fg, &repo, &ver, dry)816 }817 None => {818 let rows = load_all(fg);819 let ver = rows820 .last()821 .map(|r| r.version.clone())822 .ok_or_else(|| CliError::Runtime("no releases".to_string()))?;823 install_ver(fs, fg, &repo, &ver, dry)824 }825 }826 }827 828 pub fn run(args_in: &[String], fg: &Forge) -> Result<(), CliError> {829 if args_in.is_empty() {830 return Err(usage("f0rge release <create|list|view|install> [ver|main] (try: f0rge release -h)"));831 }832 let verb = clean(&args_in[0]);833 if verb.is_empty() || verb.starts_with('-') || !["create", "list", "view", "install"].contains(&verb.as_str()) {834 let shown = if verb.is_empty() { "<none>".to_string() } else { verb };835 return Err(CliError::Usage(format!("unknown verb: {shown} (try: f0rge release -h)")));836 }837 let fs = args::parse_flags(&args_in[1..], &spec()).map_err(|e| with_try(e, &verb))?;838 if fs.is("help") {839 if let Some(h) = verb_help(&verb) {840 output::out(h);841 return Ok(());842 }843 }844 let r: Result<(), CliError> = match verb.as_str() {845 "create" => create(&fs, fg),846 "list" => list(&fs, fg),847 "install" => {848 if fs.positional.len() > 1 {849 return Err(with_try(usage("f0rge release install [ver|main] [--dry-run]"), &verb));850 }851 install(&fs, fg, fs.positional.first().cloned())852 }853 "view" => {854 if fs.positional.len() != 1 {855 return Err(with_try(usage("f0rge release view <ver>"), &verb));856 }857 view(&fs, fg, &fs.positional[0].clone())858 }859 _ => Err(usage("f0rge release <create|list|view> [ver] (try: f0rge release -h)")),860 };861 r.map_err(|e| with_try(e, &verb))862 }863 864 #[cfg(test)]865 mod verb_help_tests {866 use super::*;867 fn budget(s: &str) {868 assert!(s.lines().count() <= 8, "lines:\n{s}");869 assert!(s.len() <= 250, "bytes {}:\n{s}", s.len());870 }871 #[test]872 fn per_verb_budgets_and_needles() {873 for (v, needle) in [874 ("create", "--notes"),875 ("list", "release list"),876 ("view", "<ver>"),877 ("install", "--dry-run"),878 ] {879 let h = verb_help(v).expect(v);880 budget(h);881 assert!(h.contains(needle), "{v} missing `{needle}`:\n{h}");882 }883 assert!(verb_help("frobnicate").is_none());884 }885 }