merge 732d3f29-2309-4aba-9864-f2d053e3d16c

55b2969gl0w

all commits

Message

merge 732d3f29-2309-4aba-9864-f2d053e3d16c

Diff

.forge/ci/steps/03_release.sh+12 −0
diff --git a/.forge/ci/steps/03_release.sh b/.forge/ci/steps/03_release.shindex 9ef77b5..3b99fd3 100755--- 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
diff --git a/.forge/wiki/docs/AGENTS.md b/.forge/wiki/docs/AGENTS.mdindex 91e4de1..abe7837 100644--- 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
diff --git a/.gitignore b/.gitignoreindex d9819d0..1aa352d 100644--- a/.gitignore+++ b/.gitignore@@ -1,4 +1,5 @@11 /target22 .forge/ci/runs/33 .forge/releases/out/4-*.tmp\ No newline at end of file4+.forge/releases/build/*/5+*.tmp
crates/cli/src/cmd/release.rs+173 −5
diff --git a/crates/cli/src/cmd/release.rs b/crates/cli/src/cmd/release.rsindex 1b2c98e..b97f639 100644--- 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
diff --git a/crates/cli/src/cmd/skill.rs b/crates/cli/src/cmd/skill.rsindex b7d2f24..dad2e9b 100644--- 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
diff --git a/crates/cli/tests/install.rs b/crates/cli/tests/install.rsnew file mode 100644index 0000000..9c37105--- /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
diff --git a/skill/skill.md b/skill/skill.mdindex 27079ab..91d2a78 100644--- 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
diff --git a/spec/schema.md b/spec/schema.mdindex 6e70364..dee98df 100644--- 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