Skip to content

Commit 9eb2b29

Browse files
authored
feat(cli): unify coven-code as a managed engine (Phase 0+1) (#346)
Phase 0+1 of the CLI unification: coven becomes the single user-facing CLI, driving coven-code as a managed subprocess (never linked; MIT/GPL boundary). - docs/ENGINE-CONTRACT.md (contract_version 1): the CLI/env/stream surfaces coven relies on, verified against the real engine - engine.rs resolver: COVEN_ENGINE_BIN -> managed ~/.coven/engine -> PATH -> legacy ~/.coven-code/bin, with MIN_ENGINE_VERSION gate - engine_install.rs: coven engine install with archive SHA-256 verified before extraction (fail closed), RAII scratch cleanup, atomic activation - coven engine status|install|which, first-run auto-install prompt (TTY-gated, COVEN_NO_AUTO_INSTALL opt-out), auth/models/acp passthroughs, coven code escape hatch, doctor Engine section with 5s-bounded auth probe Checksum pinning arrives with engine.lock in Phase 2 (feat/engine-lock).
1 parent 0912197 commit 9eb2b29

5 files changed

Lines changed: 1033 additions & 108 deletions

File tree

crates/coven-cli/src/engine.rs

Lines changed: 329 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,329 @@
1+
//! Managed engine (coven-code) resolution and version gating.
2+
//!
3+
//! LICENSE BOUNDARY: the engine is GPL-3.0; coven is MIT. The engine is
4+
//! always a separate process launched by path — never a Cargo dependency.
5+
//! Do not add any claurst-* crate to this workspace.
6+
7+
use anyhow::{anyhow, bail, Context, Result};
8+
use std::ffi::OsStr;
9+
use std::path::{Path, PathBuf};
10+
use std::process::Command;
11+
12+
#[cfg(windows)]
13+
pub const ENGINE_BIN_NAME: &str = "coven-code.exe";
14+
#[cfg(not(windows))]
15+
pub const ENGINE_BIN_NAME: &str = "coven-code";
16+
17+
/// Oldest engine this coven build can drive (contract v1 surfaces).
18+
pub const MIN_ENGINE_VERSION: (u64, u64, u64) = (0, 6, 1);
19+
20+
#[derive(Debug)]
21+
pub enum EngineSource {
22+
EnvOverride, // COVEN_ENGINE_BIN
23+
Managed, // ~/.coven/engine/<current>/
24+
PathLookup, // coven-code on PATH
25+
LegacyHome, // ~/.coven-code/bin/ (pre-unification installs)
26+
}
27+
28+
#[derive(Debug)]
29+
pub struct ResolvedEngine {
30+
pub path: PathBuf,
31+
pub source: EngineSource,
32+
}
33+
34+
pub fn resolve() -> Option<ResolvedEngine> {
35+
let env_override = std::env::var_os("COVEN_ENGINE_BIN");
36+
let path_var = std::env::var_os("PATH");
37+
let home = dirs_next::home_dir();
38+
resolve_from(
39+
env_override.as_deref(),
40+
path_var.as_deref(),
41+
home.as_deref(),
42+
)
43+
}
44+
45+
pub fn resolve_from(
46+
env_override: Option<&OsStr>,
47+
path_var: Option<&OsStr>,
48+
home: Option<&Path>,
49+
) -> Option<ResolvedEngine> {
50+
// 1. COVEN_ENGINE_BIN explicit override
51+
if let Some(override_path) = env_override {
52+
let p = PathBuf::from(override_path);
53+
if is_executable(&p) {
54+
return Some(ResolvedEngine {
55+
path: p,
56+
source: EngineSource::EnvOverride,
57+
});
58+
}
59+
// Set but not executable: fall through to next source
60+
}
61+
62+
// 2. Managed ~/.coven/engine/<current>/ENGINE_BIN_NAME
63+
if let Some(home) = home {
64+
let current_file = home.join(".coven").join("engine").join("current");
65+
if let Ok(version) = std::fs::read_to_string(&current_file) {
66+
let version = version.trim();
67+
if !version.is_empty() {
68+
let candidate = home
69+
.join(".coven")
70+
.join("engine")
71+
.join(version)
72+
.join(ENGINE_BIN_NAME);
73+
if is_executable(&candidate) {
74+
return Some(ResolvedEngine {
75+
path: candidate,
76+
source: EngineSource::Managed,
77+
});
78+
}
79+
}
80+
}
81+
}
82+
83+
// 3. PATH lookup (honor Windows multi-name)
84+
if let Some(path_var) = path_var {
85+
for dir in std::env::split_paths(path_var) {
86+
for name in engine_bin_names() {
87+
let candidate = dir.join(name);
88+
if is_executable(&candidate) {
89+
return Some(ResolvedEngine {
90+
path: candidate,
91+
source: EngineSource::PathLookup,
92+
});
93+
}
94+
}
95+
}
96+
}
97+
98+
// 4. Legacy ~/.coven-code/bin/ (pre-unification installs)
99+
if let Some(home) = home {
100+
let bin_dir = home.join(".coven-code").join("bin");
101+
for name in engine_bin_names() {
102+
let candidate = bin_dir.join(name);
103+
if is_executable(&candidate) {
104+
return Some(ResolvedEngine {
105+
path: candidate,
106+
source: EngineSource::LegacyHome,
107+
});
108+
}
109+
}
110+
}
111+
112+
None
113+
}
114+
115+
/// Resolve or produce the single actionable "engine missing" error.
116+
pub fn require() -> Result<ResolvedEngine> {
117+
resolve().ok_or_else(|| {
118+
anyhow!(
119+
"The Coven engine is not installed.\n\n Run: coven engine install\n\n\
120+
(or set COVEN_ENGINE_BIN to an existing coven-code binary)"
121+
)
122+
})
123+
}
124+
125+
pub fn engine_version(binary: &Path) -> Result<(u64, u64, u64)> {
126+
let out = Command::new(binary)
127+
.arg("--version")
128+
.output()
129+
.with_context(|| format!("failed to run {} --version", binary.display()))?;
130+
if !out.status.success() {
131+
bail!("{} --version exited nonzero", binary.display());
132+
}
133+
let text = String::from_utf8_lossy(&out.stdout);
134+
parse_version_output(&text)
135+
.ok_or_else(|| anyhow!("unparseable engine version output: {text:?}"))
136+
}
137+
138+
pub fn parse_version_output(text: &str) -> Option<(u64, u64, u64)> {
139+
// Take the last whitespace-separated token, strip a leading 'v',
140+
// split on '.' into 3 parts, parse patch up to the first non-digit.
141+
// e.g. "coven-code 0.6.1\n" -> Some((0, 6, 1))
142+
// "coven-code 0.6.1-rc1" -> Some((0, 6, 1))
143+
// "garbage" -> None
144+
let token = text.split_whitespace().last()?;
145+
let token = token.strip_prefix('v').unwrap_or(token);
146+
let mut parts = token.splitn(3, '.');
147+
let major = parts.next()?.parse::<u64>().ok()?;
148+
let minor = parts.next()?.parse::<u64>().ok()?;
149+
let patch_str = parts.next()?;
150+
// Parse patch up to the first non-digit character
151+
let digit_end = patch_str
152+
.find(|c: char| !c.is_ascii_digit())
153+
.unwrap_or(patch_str.len());
154+
let patch = patch_str[..digit_end].parse::<u64>().ok()?;
155+
Some((major, minor, patch))
156+
}
157+
158+
pub fn version_meets_minimum(v: (u64, u64, u64)) -> bool {
159+
v >= MIN_ENGINE_VERSION
160+
}
161+
162+
/// Human-readable error when the resolved engine is older than the minimum.
163+
/// Pure (no I/O) so it can be unit-tested; used by the delegation handshake.
164+
pub fn engine_too_old_message(
165+
binary: &Path,
166+
actual: (u64, u64, u64),
167+
min: (u64, u64, u64),
168+
) -> String {
169+
format!(
170+
"The Coven engine at {} is version {}.{}.{}, older than the minimum \
171+
{}.{}.{} this coven build requires.\n\n Run: coven engine install",
172+
binary.display(),
173+
actual.0,
174+
actual.1,
175+
actual.2,
176+
min.0,
177+
min.1,
178+
min.2,
179+
)
180+
}
181+
182+
/// Returns the list of candidate binary names to look up, in priority order.
183+
/// On Windows: exe, cmd, bat shims. On non-Windows: just the bare name.
184+
fn engine_bin_names() -> &'static [&'static str] {
185+
if cfg!(windows) {
186+
&["coven-code.exe", "coven-code.cmd", "coven-code.bat"]
187+
} else {
188+
&["coven-code"]
189+
}
190+
}
191+
192+
/// Check whether a path is an executable file.
193+
/// Unix: file must exist and have at least one executable bit set.
194+
/// Non-Unix: file must exist (is_file()).
195+
fn is_executable(path: &Path) -> bool {
196+
if !path.is_file() {
197+
return false;
198+
}
199+
#[cfg(unix)]
200+
{
201+
use std::os::unix::fs::PermissionsExt;
202+
std::fs::metadata(path)
203+
.map(|m| m.permissions().mode() & 0o111 != 0)
204+
.unwrap_or(false)
205+
}
206+
#[cfg(not(unix))]
207+
{
208+
true
209+
}
210+
}
211+
212+
#[cfg(test)]
213+
mod tests {
214+
use super::*;
215+
use std::fs;
216+
217+
fn touch_exec(path: &std::path::Path) {
218+
fs::create_dir_all(path.parent().unwrap()).unwrap();
219+
fs::write(path, b"#!/bin/sh\n").unwrap();
220+
#[cfg(unix)]
221+
{
222+
use std::os::unix::fs::PermissionsExt;
223+
fs::set_permissions(path, fs::Permissions::from_mode(0o755)).unwrap();
224+
}
225+
}
226+
227+
#[test]
228+
fn managed_engine_wins_over_path_and_legacy() {
229+
let home = tempfile::tempdir().unwrap();
230+
let managed = home
231+
.path()
232+
.join(".coven/engine/0.6.1")
233+
.join(ENGINE_BIN_NAME);
234+
let legacy = home.path().join(".coven-code/bin").join(ENGINE_BIN_NAME);
235+
touch_exec(&managed);
236+
touch_exec(&legacy);
237+
fs::write(home.path().join(".coven/engine/current"), "0.6.1").unwrap();
238+
let r = resolve_from(None, None, Some(home.path())).unwrap();
239+
assert_eq!(r.path, managed);
240+
assert!(matches!(r.source, EngineSource::Managed));
241+
}
242+
243+
#[test]
244+
fn env_override_beats_everything() {
245+
let home = tempfile::tempdir().unwrap();
246+
let custom = home.path().join("custom-engine");
247+
touch_exec(&custom);
248+
let r = resolve_from(Some(custom.as_os_str()), None, Some(home.path())).unwrap();
249+
assert!(matches!(r.source, EngineSource::EnvOverride));
250+
assert_eq!(r.path, custom);
251+
}
252+
253+
#[test]
254+
fn falls_back_to_legacy_home_dir() {
255+
let home = tempfile::tempdir().unwrap();
256+
let legacy = home.path().join(".coven-code/bin").join(ENGINE_BIN_NAME);
257+
touch_exec(&legacy);
258+
let r = resolve_from(None, None, Some(home.path())).unwrap();
259+
assert!(matches!(r.source, EngineSource::LegacyHome));
260+
}
261+
262+
#[test]
263+
fn resolves_none_when_absent() {
264+
let home = tempfile::tempdir().unwrap();
265+
assert!(resolve_from(None, None, Some(home.path())).is_none());
266+
}
267+
268+
#[test]
269+
fn parses_clap_version_line() {
270+
assert_eq!(parse_version_output("coven-code 0.6.1\n"), Some((0, 6, 1)));
271+
assert_eq!(parse_version_output("garbage"), None);
272+
}
273+
274+
#[test]
275+
fn min_version_gate() {
276+
assert!(version_meets_minimum((0, 6, 1)));
277+
assert!(!version_meets_minimum((0, 5, 9)));
278+
}
279+
280+
#[cfg(unix)]
281+
#[test]
282+
fn env_override_non_executable_falls_through() {
283+
let home = tempfile::tempdir().unwrap();
284+
let not_exec = home.path().join("not-exec");
285+
fs::write(&not_exec, b"").unwrap(); // written without the exec bit
286+
let legacy = home.path().join(".coven-code/bin").join(ENGINE_BIN_NAME);
287+
touch_exec(&legacy);
288+
let r = resolve_from(Some(not_exec.as_os_str()), None, Some(home.path())).unwrap();
289+
assert!(matches!(r.source, EngineSource::LegacyHome));
290+
}
291+
292+
#[test]
293+
fn path_lookup_finds_windows_cmd_shim_name() {
294+
// The resolver's PathLookup must honor every platform bin-name, mirroring
295+
// the npm .cmd shim discovery the old main.rs helper covered.
296+
let names = engine_bin_names();
297+
if cfg!(windows) {
298+
assert!(names.contains(&"coven-code.cmd"));
299+
} else {
300+
assert_eq!(names, &["coven-code"]);
301+
}
302+
}
303+
304+
#[test]
305+
fn engine_too_old_message_names_version_and_install_command() {
306+
let msg =
307+
engine_too_old_message(std::path::Path::new("/x/coven-code"), (0, 5, 9), (0, 6, 1));
308+
assert!(msg.contains("0.5.9"));
309+
assert!(msg.contains("0.6.1"));
310+
assert!(msg.contains("coven engine install"));
311+
assert!(msg.contains("/x/coven-code"));
312+
}
313+
314+
#[test]
315+
fn whitespace_only_current_file_is_ignored() {
316+
let home = tempfile::tempdir().unwrap();
317+
// A managed binary exists, but `current` is blank → managed source must be skipped.
318+
let managed = home
319+
.path()
320+
.join(".coven/engine/0.6.1")
321+
.join(ENGINE_BIN_NAME);
322+
touch_exec(&managed);
323+
fs::write(home.path().join(".coven/engine/current"), " \n").unwrap();
324+
let legacy = home.path().join(".coven-code/bin").join(ENGINE_BIN_NAME);
325+
touch_exec(&legacy);
326+
let r = resolve_from(None, None, Some(home.path())).unwrap();
327+
assert!(matches!(r.source, EngineSource::LegacyHome));
328+
}
329+
}

0 commit comments

Comments
 (0)