merge 732d3f29-2309-4aba-9864-f2d053e3d16c
Message
merge 732d3f29-2309-4aba-9864-f2d053e3d16c
Diff
.forge/ci/steps/03_release.sh+12 −0
--- a/.forge/ci/steps/03_release.sh+++ b/.forge/ci/steps/03_release.sh@@ -3,3 +3,15 @@33 set -e44 export CARGO_BUILD_JOBS=255 cargo build --locked --release6+# Publish prebuilt artifact for `f0rge release install` fast path:7+# one binary per release record (gitignored, never committed).8+if [ -f target/release/f0rge ]; then9+ for rec in .forge/releases/v*.md; do10+ [ -e "$rec" ] || continue11+ ver=$(basename "$rec" .md)12+ dest=".forge/releases/build/$ver/f0rge"13+ mkdir -p ".forge/releases/build/$ver"14+ cp -f target/release/f0rge "$dest"15+ chmod +x "$dest"16+ done17+fi
.forge/wiki/docs/AGENTS.md+15 −0
--- a/.forge/wiki/docs/AGENTS.md+++ b/.forge/wiki/docs/AGENTS.md@@ -10,6 +10,21 @@ f0rge — a local, gh-literal git forge: state lives in `.forge/` files,1010 driven by a thin Rust CLI, built for one owner and their swarm. This repo1111 dogfoods: building f0rge is the first f0rge project (from PLAN M3 on).1212 13+## Install14+15+One command, from a clone:16+17+- latest release: `f0rge release install`18+- pinned version: `f0rge release install v0.1.1` (needs the git tag)19+- moving tip: `f0rge release install main` (builds current checkout)20+- preview only: append `--dry-run` (prints each step, changes nothing)21+22+No binary yet (fresh machine)? `cargo install --locked --path .`23+from the clone, then `f0rge release install` keeps you current.24+A prebuilt `.forge/releases/build/<ver>/f0rge` copies instantly when25+present; otherwise the ref compiles via `cargo install --locked`26+into `$CARGO_HOME/bin` (else `~/.cargo/bin`).27+1328 ## Reading order1429 1530 1. `AGENTS.md` — this file
.gitignore+2 −1
--- a/.gitignore+++ b/.gitignore@@ -1,4 +1,5 @@11 /target22 .forge/ci/runs/33 .forge/releases/out/4-*.tmp4+.forge/releases/build/*/5+*.tmp
crates/cli/src/cmd/release.rs+173 −5
--- a/crates/cli/src/cmd/release.rs+++ b/crates/cli/src/cmd/release.rs@@ -12,11 +12,11 @@ use std::process::Command;1212 use std::sync::atomic::{AtomicU64, Ordering};1313 1414 pub fn help() -> &'static str {15- "f0rge release: versions\nusage: f0rge release <create|list|view> [ver] [--json]\nnotes: .forge/releases/<ver>.md\ndocs: spec/schema.md §Release\ncontract: f0rge --skill\n"15+ "f0rge release: versions\nusage: f0rge release <create|list|view|install> [ver|main] [--json|--dry-run]\nnotes: .forge/releases/<ver>.md\ndocs: spec/schema.md §Release\ncontract: f0rge --skill\n"1616 }1717 1818 fn spec() -> Vec<args::FlagSpec> {19- args::spec_from_lists(&["notes", "title", "jq"], &["json"])19+ args::spec_from_lists(&["notes", "title", "jq"], &["json", "dry-run"])2020 }2121 2222 fn usage(msg: &str) -> CliError {@@ -442,18 +442,186 @@ fn view(fs: &args::FlagSet, fg: &Forge, raw: &str) -> Result<(), CliError> {442442 Ok(())443443 }444444 445+// ---- install (local machine op; never forge state, never CI) ----446+447+#[derive(Debug, Clone, Serialize)]448+struct InstallJson {449+ path: String,450+ sha256: String,451+ #[serde(rename = "ref")]452+ ref_: String,453+ method: String,454+}455+456+/// Install destination: $CARGO_HOME/bin/f0rge, else ~/.cargo/bin/f0rge.457+fn cargo_dest() -> Result<PathBuf, CliError> {458+ if let Ok(h) = std::env::var("CARGO_HOME") {459+ if !h.is_empty() {460+ return Ok(PathBuf::from(h).join("bin").join("f0rge"));461+ }462+ }463+ match std::env::var("HOME") {464+ Ok(h) if !h.is_empty() => Ok(PathBuf::from(h).join(".cargo").join("bin").join("f0rge")),465+ _ => Err(CliError::Runtime("no home".to_string())),466+ }467+}468+469+fn tag_exists(repo: &Path, tag: &str) -> bool {470+ git(repo, &["rev-parse", "--verify", tag])471+}472+473+fn emit_installed(fs: &args::FlagSet, dest: &Path, sha: &str, rf: &str, method: &str) {474+ if fs.is("json") {475+ output::emit_json(476+ &InstallJson {477+ path: dest.display().to_string(),478+ sha256: sha.to_string(),479+ ref_: rf.to_string(),480+ method: method.to_string(),481+ },482+ fs.get("jq"),483+ );484+ return;485+ }486+ output::out(&format!("installed {} {} {}\n", dest.display(), sha, rf));487+}488+489+fn cargo_install_from(src: &Path) -> Result<(), CliError> {490+ let st = Command::new("cargo")491+ .arg("install")492+ .arg("--locked")493+ .arg("--path")494+ .arg(src)495+ .stdout(std::process::Stdio::null())496+ .stderr(std::process::Stdio::null())497+ .status();498+ match st {499+ Ok(s) if s.success() => Ok(()),500+ _ => Err(CliError::Runtime("install failed".to_string())),501+ }502+}503+504+/// Pinned/tag install: fast copy of releases/build/<ver>/f0rge, else505+/// compile from a temp tag checkout (argv-only, worktree cleaned up).506+fn install_ver(507+ fs: &args::FlagSet,508+ fg: &Forge,509+ repo: &Path,510+ ver: &str,511+ dry: bool,512+) -> Result<(), CliError> {513+ let dest = cargo_dest()?;514+ let artifact = fg.root().join("releases").join("build").join(ver).join("f0rge");515+ if artifact.is_file() {516+ if dry {517+ output::out(&format!("would copy {} to {}\n", artifact.display(), dest.display()));518+ return Ok(());519+ }520+ if let Some(p) = dest.parent() {521+ let _ = std::fs::create_dir_all(p);522+ }523+ std::fs::copy(&artifact, &dest)524+ .map_err(|_| CliError::Runtime("install failed".to_string()))?;525+ #[cfg(unix)]526+ {527+ use std::os::unix::fs::PermissionsExt;528+ let _ =529+ std::fs::set_permissions(&dest, std::fs::Permissions::from_mode(0o755));530+ }531+ let sha = sha256_of(&dest).unwrap_or_default();532+ emit_installed(fs, &dest, &sha, ver, "copy");533+ return Ok(());534+ }535+ if dry {536+ output::out(&format!(537+ "would run: cargo install --locked --path <checkout of {ver}> to {}\n",538+ dest.display()539+ ));540+ return Ok(());541+ }542+ match try_worktree(repo, ver) {543+ Some(wt) => {544+ let r = cargo_install_from(&wt);545+ cleanup_worktree(repo, &wt);546+ r?;547+ }548+ None => cargo_install_from(repo)?,549+ }550+ let sha = sha256_of(&dest).unwrap_or_default();551+ emit_installed(fs, &dest, &sha, ver, "compile");552+ Ok(())553+}554+555+/// Main install: compile the current checkout in place.556+fn install_main(557+ fs: &args::FlagSet,558+ fg: &Forge,559+ repo: &Path,560+ dry: bool,561+) -> Result<(), CliError> {562+ let _ = fg;563+ let dest = cargo_dest()?;564+ if dry {565+ output::out(&format!(566+ "would run: cargo install --locked --path {} (main) to {}\n",567+ repo.display(),568+ dest.display()569+ ));570+ return Ok(());571+ }572+ cargo_install_from(repo)?;573+ let sha = sha256_of(&dest).unwrap_or_default();574+ emit_installed(fs, &dest, &sha, "main", "compile");575+ Ok(())576+}577+578+/// `install` = latest record; `install <ver>` = pinned tag (tag must579+/// exist, else usage `no such release: <ver>`); `install main` = cwd.580+fn install(fs: &args::FlagSet, fg: &Forge, raw: Option<String>) -> Result<(), CliError> {581+ let repo =582+ fg.root().parent().map(|p| p.to_path_buf()).unwrap_or_else(|| PathBuf::from("."));583+ let dry = fs.is("dry-run");584+ match raw {585+ Some(r) if clean(&r) == "main" => install_main(fs, fg, &repo, dry),586+ Some(r) => {587+ let ver = check_ver(&r)?;588+ if fg.load_entity::<Release>(&fg.release_file(&ver)).is_err() {589+ return Err(CliError::Usage(format!("no such release: {ver}")));590+ }591+ if !tag_exists(&repo, &ver) {592+ return Err(CliError::Usage(format!("no such release: {ver}")));593+ }594+ install_ver(fs, fg, &repo, &ver, dry)595+ }596+ None => {597+ let rows = load_all(fg);598+ let ver = rows599+ .last()600+ .map(|r| r.version.clone())601+ .ok_or_else(|| CliError::Runtime("no releases".to_string()))?;602+ install_ver(fs, fg, &repo, &ver, dry)603+ }604+ }605+}606+445607 pub fn run(args_in: &[String], fg: &Forge) -> Result<(), CliError> {446608 if args_in.is_empty() {447- return Err(usage("f0rge release <create|list|view> [ver]"));609+ return Err(usage("f0rge release <create|list|view|install> [ver|main]"));448610 }449611 let verb = clean(&args_in[0]);450- if verb.is_empty() || verb.starts_with('-') || !["create", "list", "view"].contains(&verb.as_str()) {451- return Err(usage("f0rge release <create|list|view> [ver]"));612+ if verb.is_empty() || verb.starts_with('-') || !["create", "list", "view", "install"].contains(&verb.as_str()) {613+ return Err(usage("f0rge release <create|list|view|install> [ver|main]"));452614 }453615 let fs = args::parse_flags(&args_in[1..], &spec())?;454616 match verb.as_str() {455617 "create" => create(&fs, fg),456618 "list" => list(&fs, fg),619+ "install" => {620+ if fs.positional.len() > 1 {621+ return Err(usage("f0rge release install [ver|main] [--dry-run]"));622+ }623+ install(&fs, fg, fs.positional.first().cloned())624+ }457625 "view" => {458626 if fs.positional.len() != 1 {459627 return Err(usage("f0rge release view <ver>"));
crates/cli/src/cmd/skill.rs+2 −1
--- a/crates/cli/src/cmd/skill.rs+++ b/crates/cli/src/cmd/skill.rs@@ -21,7 +21,8 @@ never delete: close/archive only; archive to .forge/archive/\n\2121 wiki: files under wiki/; edit in place/stdin\n\2222 spec: spec/schema.md frozen truth; labels.md/milestones.md registries\n\2323 usage: f0rge <cmd> [--json] [args]; init makes .forge/ idempotent; --jq via jq; never parse prose\n\24-json: compact fixed order; empty []/{}\n";24+json: compact fixed order; empty []/{}\n\25+install: f0rge release install [ver|main]\n";2526 2627 pub fn run(args: &[String], _fg: &f0rge_core::Forge) -> Result<(), CliError> {2728 for a in args {
crates/cli/tests/install.rs+169 −0
--- /dev/null+++ b/crates/cli/tests/install.rs@@ -0,0 +1,169 @@1+//! #32: release install (latest|ver|main) + fast copy + dry-run + 03_release artifact.2+3+use std::path::{Path, PathBuf};4+use std::process::Command;5+6+fn bin() -> Command {7+ Command::new(env!("CARGO_BIN_EXE_f0rge"))8+}9+10+fn run_in(dir: &Path, args: &[&str], envs: &[(&str, &str)]) -> (String, String, i32) {11+ let mut c = bin();12+ c.current_dir(dir).args(args);13+ for (k, v) in envs {14+ c.env(k, v);15+ }16+ let out = c.output().expect("run f0rge");17+ (18+ String::from_utf8_lossy(&out.stdout).into_owned(),19+ String::from_utf8_lossy(&out.stderr).into_owned(),20+ out.status.code().unwrap_or(-1),21+ )22+}23+24+fn tmp(tag: &str) -> PathBuf {25+ let d =26+ std::env::temp_dir().join(format!("f0rge-install-{tag}-{}-{}", std::process::id(), line!()));27+ let _ = std::fs::remove_dir_all(&d);28+ std::fs::create_dir_all(&d).unwrap();29+ d30+}31+32+fn record(dir: &Path, ver: &str, created: &str) {33+ let body = format!("---\nversion: {ver}\ncreated: {created}\n---\nnotes {ver}\n");34+ std::fs::write(dir.join(".forge/releases").join(format!("{ver}.md")), body).unwrap();35+}36+37+fn git(dir: &Path, args: &[&str]) {38+ let st = Command::new("git").current_dir(dir).args(args).status().expect("git");39+ assert!(st.success(), "git {args:?} failed");40+}41+42+#[test]43+fn install_dry_run_latest_and_main() {44+ let dir = tmp("dry");45+ assert_eq!(run_in(&dir, &["init"], &[]).2, 0);46+ record(&dir, "v0.1.0", "2026-01-01T00:00:00Z");47+ record(&dir, "v0.1.1", "2026-01-02T00:00:00Z");48+ let home = dir.join("home");49+ let ch = home.join("cargo-home");50+ let dest = ch.join("bin").join("f0rge");51+52+ // latest = newest record by (created, version): v0.1.1, no artifact/git -> compile dry-run.53+ let (o, err, code) =54+ run_in(&dir, &["release", "install", "--dry-run"], &[("CARGO_HOME", &ch.to_string_lossy())]);55+ assert_eq!((code, err.as_str()), (0, ""), "stdout:\n{o}");56+ assert!(o.contains("v0.1.1"), "latest must resolve v0.1.1:\n{o}");57+ assert!(o.contains("would run:") && o.contains("cargo install"), "dry compile:\n{o}");58+ assert_eq!(o.lines().count(), 1, "one line per step:\n{o}");59+ assert!(!dest.exists(), "dry-run touches nothing");60+61+ // main = current checkout.62+ let (o, err, code) =63+ run_in(&dir, &["release", "install", "main", "--dry-run"], &[("CARGO_HOME", &ch.to_string_lossy())]);64+ assert_eq!((code, err.as_str()), (0, ""), "stdout:\n{o}");65+ assert!(o.contains("cargo install") && o.contains("main"), "main dry:\n{o}");66+ assert_eq!(o.lines().count(), 1, "one line:\n{o}");67+ assert!(!dest.exists(), "dry-run touches nothing");68+69+ let _ = std::fs::remove_dir_all(&dir);70+}71+72+#[test]73+fn install_ver_pinned_needs_tag() {74+ let dir = tmp("pin");75+ assert_eq!(run_in(&dir, &["init"], &[]).2, 0);76+ record(&dir, "v9.9.9", "2026-01-01T00:00:00Z");77+ git(&dir, &["init", "-q"]);78+ git(&dir, &["config", "user.email", "t@t"]);79+ git(&dir, &["config", "user.name", "t"]);80+ git(&dir, &["add", "-A"]);81+ git(&dir, &["commit", "-qm", "init"]);82+ git(&dir, &["tag", "v9.9.9"]);83+84+ let (o, err, code) =85+ run_in(&dir, &["release", "install", "v9.9.9", "--dry-run"], &[("HOME", "/nonexistent-x")]);86+ assert_eq!((code, err.as_str()), (0, ""), "stdout:\n{o}");87+ assert!(o.contains("v9.9.9"), "pinned resolves tag:\n{o}");88+89+ // unknown version (no record, no tag) -> usage, one line.90+ let (o, err, code) = run_in(&dir, &["release", "install", "v0.0.0", "--dry-run"], &[]);91+ assert_eq!(code, 2, "stdout:\n{o}\nstderr:\n{err}");92+ assert_eq!(err.lines().count(), 1, "stderr:\n{err}");93+ assert!(err.contains("no such release: v0.0.0"), "stderr:\n{err}");94+ assert_eq!(o, "");95+96+ // record exists but tag missing -> same usage error.97+ record(&dir, "v8.8.8", "2026-01-03T00:00:00Z");98+ let (_, err, code) = run_in(&dir, &["release", "install", "v8.8.8", "--dry-run"], &[]);99+ assert_eq!(code, 2);100+ assert!(err.contains("no such release: v8.8.8"), "stderr:\n{err}");101+102+ let _ = std::fs::remove_dir_all(&dir);103+}104+105+#[test]106+fn install_copies_prebuilt_to_cargo_home() {107+ let dir = tmp("copy");108+ assert_eq!(run_in(&dir, &["init"], &[]).2, 0);109+ record(&dir, "v1.2.3", "2026-01-01T00:00:00Z");110+ // prebuilt artifact for the resolved ref.111+ let art = dir.join(".forge/releases/build/v1.2.3/f0rge");112+ std::fs::create_dir_all(art.parent().unwrap()).unwrap();113+ std::fs::write(&art, b"fake-f0rge-binary").unwrap();114+115+ let ch = dir.join("chome");116+ let dest = ch.join("bin").join("f0rge");117+ // latest == v1.2.3 -> fast path, real copy (no compile).118+ let (o, err, code) =119+ run_in(&dir, &["release", "install"], &[("CARGO_HOME", &ch.to_string_lossy())]);120+ assert_eq!((code, err.as_str()), (0, ""), "stdout:\n{o}");121+ assert_eq!(std::fs::read(&dest).unwrap(), b"fake-f0rge-binary");122+ assert!(o.contains("installed") && o.contains("v1.2.3"), "prints path+sha+ref:\n{o}");123+ assert!(o.contains(&dest.display().to_string()), "prints dest path:\n{o}");124+ #[cfg(unix)]125+ {126+ use std::os::unix::fs::PermissionsExt;127+ assert!(std::fs::metadata(&dest).unwrap().permissions().mode() & 0o111 != 0, "chmod +x");128+ }129+130+ let _ = std::fs::remove_dir_all(&dir);131+}132+133+#[test]134+fn release_step_green_on_temp_repo_with_artifact() {135+ let dir = tmp("step");136+ assert_eq!(run_in(&dir, &["init"], &[]).2, 0);137+ record(&dir, "v0.0.1", "2026-01-01T00:00:00Z");138+ // stub cargo: pretend the build produced target/release/f0rge.139+ let stub = dir.join("stubbin");140+ std::fs::create_dir_all(&stub).unwrap();141+ std::fs::write(142+ stub.join("cargo"),143+ "#!/bin/sh\nmkdir -p target/release\necho stub-f0rge > target/release/f0rge\nchmod +x target/release/f0rge\nexit 0\n",144+ )145+ .unwrap();146+ #[cfg(unix)]147+ {148+ use std::os::unix::fs::PermissionsExt;149+ std::fs::set_permissions(stub.join("cargo"), std::fs::Permissions::from_mode(0o755)).unwrap();150+ }151+ let step = concat!(152+ env!("CARGO_MANIFEST_DIR"),153+ "/../../.forge/ci/steps/03_release.sh"154+ );155+ let path = format!("{}:{}", stub.display(), std::env::var("PATH").unwrap_or_default());156+ let st = Command::new("sh")157+ .current_dir(&dir)158+ .arg(step)159+ .env("PATH", &path)160+ .env("CARGO_BUILD_JOBS", "2")161+ .status()162+ .expect("run 03_release.sh");163+ assert!(st.success(), "03_release green on temp repo");164+ let art = dir.join(".forge/releases/build/v0.0.1/f0rge");165+ assert!(art.is_file(), "artifact dropped per record");166+ assert_eq!(std::fs::read(&art).unwrap(), b"stub-f0rge\n");167+168+ let _ = std::fs::remove_dir_all(&dir);169+}
skill/skill.md+1 −0
--- a/skill/skill.md+++ b/skill/skill.md@@ -16,3 +16,4 @@ wiki: files under wiki/; edit in place/stdin1616 spec: spec/schema.md frozen truth; labels.md/milestones.md registries1717 usage: f0rge <cmd> [--json] [args]; init makes .forge/ idempotent; --jq via jq; never parse prose1818 json: compact fixed order; empty []/{}19+install: f0rge release install [ver|main]
spec/schema.md+13 −0
--- a/spec/schema.md+++ b/spec/schema.md@@ -139,6 +139,19 @@ Golden: `spec/fixtures/ci-run.md`.139139 to the notes body. Empty build dir = no-op.140140 - `release list` order: `(created, version)`; `release view <ver>`141141 prints the notes body. No delete verb (nothing is deleted).142+- Install (local machine op; never forge state, never CI):143+ `release install` (latest record), `release install <ver>` (pinned:144+ tag `<ver>` must exist else `no such release: <ver>`, exit 2),145+ `release install main` (current checkout). Fast path: copy prebuilt146+ `releases/build/<ver>/f0rge` to `$CARGO_HOME/bin` (else147+ `~/.cargo/bin`), chmod +x; else `cargo install --locked` from a148+ temp tag checkout (tags) or the current tree (main). Prints149+ `installed <path> <sha256> <ref>`. `--dry-run` prints each step150+ (`would ...`, one line each) without touching the machine.151+- Build artifact: CI copies `target/release/f0rge` to152+ `releases/build/<ver>/f0rge` per release record (gitignored,153+ never committed); `status: built|none` still derives from154+ `out/<ver>/`.142155 143156 ### init — bootstrap skeleton, idempotent144157 - `init [dir]` (default cwd) creates dirs `issues prs tasks