use crate::publication::Signals;
use anyhow::{Context, Result, ensure};
use clap::Subcommand;
use forge_adapters::process;
use serde_json::{Value, json};
use std::{
fs,
io::Write,
path::{Path, PathBuf},
};
const GREETING: &[u8] = b"Hello Forge\n";
const PRESERVED: &[u8] = b"keep\n";
#[derive(Subcommand)]
pub enum Command {
Create { directory: PathBuf },
#[command(hide = true)]
Write,
#[command(hide = true)]
Verify { root: PathBuf },
}
pub async fn dispatch(command: &Command) -> Result<Value> {
match command {
Command::Create { directory } => create(directory).await,
Command::Write => {
write_new(&std::env::current_dir()?.join("hello.txt"), GREETING)?;
Ok(json!({"schema_version":1,"status":"written"}))
}
Command::Verify { root } => {
let root = crate::installation::safe_path(root)?;
check(&root.join("hello.txt"), GREETING)?;
check(&root.join("preserved.txt"), PRESERVED)?;
Ok(
json!({"schema_version":1,"status":"passed","checks":["greeting artifact","unrelated file preserved"],"native_inference_calls":0}),
)
}
}
}
fn write_new(path: &Path, bytes: &[u8]) -> Result<()> {
crate::installation::safe_path(path)?;
let mut file = fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(path)
.with_context(|| format!("refusing to replace an existing file: {}", path.display()))?;
file.write_all(bytes)?;
Ok(())
}
fn check(path: &Path, expected: &[u8]) -> Result<()> {
crate::installation::safe_path(path)?;
let meta = fs::symlink_metadata(path)?;
ensure!(
meta.is_file() && meta.len() == expected.len() as u64,
"demo evidence missing or changed: {}",
path.display()
);
ensure!(
fs::read(path)? == expected,
"demo evidence differs: {}",
path.display()
);
Ok(())
}
async fn create(directory: &Path) -> Result<Value> {
for key in [
"GIT_DIR",
"GIT_WORK_TREE",
"GIT_COMMON_DIR",
"GIT_INDEX_FILE",
"GIT_OBJECT_DIRECTORY",
"GIT_ALTERNATE_OBJECT_DIRECTORIES",
"GIT_CONFIG",
] {
ensure!(
std::env::var_os(key).is_none(),
"demo preparation refuses inherited {key}; use a shell without Git routing overrides"
);
}
ensure!(
!directory
.components()
.any(|c| matches!(c, std::path::Component::ParentDir)),
"parent traversal refused"
);
let absolute = if directory.is_absolute() {
directory.to_owned()
} else {
std::env::current_dir()?.join(directory)
};
let parent = absolute
.parent()
.context("demo parent directory required")?
.canonicalize()?;
let root = crate::installation::safe_path(
&parent.join(
absolute
.file_name()
.context("new demo directory name required")?,
),
)?;
ensure!(
root.to_str().is_some(),
"demo manifests require UTF-8 paths"
);
let binary = std::env::current_exe()?.canonicalize()?;
let binary_name = binary.to_str().context("executable path must be UTF-8")?;
let digest = crate::installation::hash(&binary)?;
fs::create_dir(&root).context(
"choose a NEW demo directory with an existing parent; existing files are preserved",
)?;
let root = root.canonicalize()?;
let repo = root.join("app");
fs::create_dir(&repo)?;
let hooks = root.join("empty-git-hooks");
fs::create_dir(&hooks)?;
let signals = Signals::new();
let hooks = hooks.to_str().context("Git hooks path must be UTF-8")?;
let template = format!("--template={hooks}");
for args in [
vec!["init", "-q", &template],
vec!["config", "--local", "core.hooksPath", hooks],
vec!["config", "--local", "commit.gpgsign", "false"],
vec!["config", "--local", "core.fsmonitor", "false"],
vec![
"-c",
"user.name=Forge Demo",
"-c",
"user.email=demo@example.invalid",
"commit",
"--allow-empty",
"-qm",
"Initialize disposable Forge example",
],
] {
let argv: Vec<String> = [
"git".to_owned(),
"-c".into(),
format!("core.hooksPath={hooks}"),
"-c".into(),
"commit.gpgsign=false".into(),
"-c".into(),
"core.fsmonitor=false".into(),
]
.into_iter()
.chain(args.into_iter().map(str::to_owned))
.collect();
let result = process::run(&argv, &repo, 30, signals.receiver.clone()).await?;
ensure!(
result.passed(),
"demo Git preparation failed; preserved {} for inspection: {}",
root.display(),
result.stderr
);
}
write_new(&repo.join("preserved.txt"), PRESERVED)?;
let manifest = root.join("release.yaml");
let mut inputs = serde_json::Map::new();
inputs.insert(binary_name.to_owned(), json!(digest));
let contract = json!({"schema_version":1,"release_id":"hello-forge","repos":{"app":{"path":"app"}},
"tasks":[{"id":"HELLO","repo":"app","description":"Native no-agent demo: greeting and preservation verified by an external gate.","timeout_seconds":30,"max_attempts":1,
"executor":{"kind":"command","argv":[binary,"demo","write"]},"acceptance":[{"kind":"gate","id":"greeting"}]}],
"gates":[{"id":"greeting","argv":[binary,"demo","verify","{repo}"],"inputs":inputs,"timeout_seconds":30}]});
write_new(&manifest, &serde_json::to_vec_pretty(&contract)?)?;
write_new(&root.join("README.md"), b"# Disposable Forge demo\n\nInspect and dry-run before executing release.yaml. State and evaluator stay outside app/.\nNo agent, Python, memory or account is needed. Replacing the Forge binary changes the pinned evaluator; recreate in a NEW directory.\n")?;
let state = root.join("state");
let prefix = vec![json!(binary), json!("--state-dir"), json!(state)];
let command = |args: &[&str]| {
let mut argv = prefix.clone();
argv.extend(args.iter().map(|v| json!(v)));
argv.push(json!(manifest));
argv
};
let mut dry_run = command(&["release", "run"]);
dry_run.push(json!("--dry-run"));
Ok(
json!({"schema_version":1,"status":"prepared","directory":root,"repo":repo,"manifest":manifest,"state_dir":state,
"executed":false,"native_inference_calls":0,"requirements":["Git"],"commands":{
"inspect":command(&["release","inspect"]),"dry_run":dry_run,"run":command(&["release","run"]),"report":command(&["report"])}}),
)
}