feat(cli): --quiet/--yes globals; echo resolved write target; gate non-local destructive writes (#243)

RFC-011 Decision 9. Writes echo their resolved target + access path to stderr (suppress with --quiet). Destructive writes (cleanup, overwrite load, branch delete) against a non-local scope require consent: --yes, a TTY prompt, or a hard refusal for non-TTY/--json runs. Local file:// writes unaffected.
This commit is contained in:
Andrew Altshuler 2026-06-15 14:35:55 +03:00 committed by GitHub
parent a09045028f
commit 2ed05d2cb1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 295 additions and 2 deletions

View file

@ -66,6 +66,18 @@ pub(crate) struct Cli {
#[arg(long, global = true, value_name = "DIR|URI")]
pub(crate) cluster: Option<String>,
/// Skip the confirmation prompt for a destructive write (`cleanup`,
/// overwrite `load`, `branch delete`) against a non-local scope (RFC-011
/// Decision 9). Without it, a non-local destructive write prompts on a TTY
/// and refuses (errors) when there is no TTY or `--json` is set.
#[arg(long, global = true)]
pub(crate) yes: bool,
/// Suppress the one-line resolved-write-target diagnostic that write
/// commands echo to stderr (RFC-011 Decision 9).
#[arg(long, global = true)]
pub(crate) quiet: bool,
#[command(subcommand)]
pub(crate) command: Command,
}

View file

@ -2,6 +2,8 @@
//! remote HTTP, env/token handling, scaffolding (moved verbatim from
//! main.rs in the modularization).
use std::io::IsTerminal;
use super::*;
use crate::operator;
@ -16,6 +18,59 @@ pub(crate) fn is_remote_uri(uri: &str) -> bool {
uri.starts_with("http://") || uri.starts_with("https://")
}
/// Whether a resolved write target is *local* for the purposes of the RFC-011
/// Decision 9 destructive-confirm gate: a bare path or a `file://` URI. Anything
/// else carrying a scheme — `http(s)://` (served), `s3://` / `gs://` / … (object
/// store) — is non-local and a destructive write against it requires explicit
/// consent. Generalizes `is_remote_uri` (which only catches http(s)).
pub(crate) fn uri_is_local(uri: &str) -> bool {
!uri.contains("://") || uri.starts_with("file://")
}
/// Echo the resolved write target + access path to stderr (RFC-011 Decision 9),
/// unless `--quiet`. One line, e.g. `omnigraph load → file://g.omni (direct,
/// local)`. stderr so `--json` consumers reading stdout are unaffected; the line
/// legitimately differs embedded-vs-served (that visibility is the point).
pub(crate) fn echo_write_target(quiet: bool, label: &str, uri: &str, served: bool) {
if quiet {
return;
}
let access = if served {
"served"
} else if uri_is_local(uri) {
"direct, local"
} else {
"direct, remote"
};
eprintln!("omnigraph {label}{uri} ({access})");
}
/// Gate a destructive write (`cleanup`, overwrite `load`, `branch delete`)
/// against a non-local scope (RFC-011 Decision 9). A local target needs no
/// confirmation; otherwise `--yes` consents, an interactive TTY is prompted, and
/// a non-TTY / `--json` run refuses rather than silently proceeding.
pub(crate) fn confirm_destructive(label: &str, uri: &str, yes: bool, json: bool) -> Result<()> {
if uri_is_local(uri) || yes {
return Ok(());
}
if json || !std::io::stdin().is_terminal() {
bail!(
"refusing destructive `{label}` against non-local target {uri} without confirmation; \
pass --yes to confirm (an interactive TTY would be prompted instead)"
);
}
eprint!(
"About to run a destructive `{label}` against {uri} (not local). Type 'yes' to continue: "
);
io::stderr().flush()?;
let mut answer = String::new();
io::stdin().read_line(&mut answer)?;
match answer.trim().to_ascii_lowercase().as_str() {
"yes" | "y" => Ok(()),
_ => bail!("aborted: destructive `{label}` not confirmed"),
}
}
/// THE one way the CLI composes a remote request URL. Every remote call
/// routes through here so URL assembly has a single mechanism instead of
/// per-callsite string interpolation.
@ -1112,6 +1167,40 @@ pub(crate) fn rewrite_deprecated_argv(args: Vec<OsString>) -> Vec<OsString> {
mod tests {
use super::*;
// RFC-011 Decision 9: locality classifier for the destructive-confirm gate.
#[test]
fn uri_is_local_truth_table() {
// Local: bare path or file://.
assert!(uri_is_local("graph.omni"));
assert!(uri_is_local("/abs/path/graph.omni"));
assert!(uri_is_local("file:///tmp/graph.omni"));
// Non-local: served or object-store schemes.
assert!(!uri_is_local("http://host/graphs/g"));
assert!(!uri_is_local("https://host/graphs/g"));
assert!(!uri_is_local("s3://bucket/graph.omni"));
assert!(!uri_is_local("gs://bucket/graph.omni"));
}
// RFC-011 Decision 9: a non-local destructive write with `--json` (the CI
// shape — also covers the no-TTY case, since tests run without a terminal)
// refuses rather than proceeding; a local one and an explicit `--yes` pass.
#[test]
fn confirm_destructive_refuses_non_local_without_consent() {
let err = confirm_destructive("cleanup", "s3://b/g.omni", false, true)
.unwrap_err()
.to_string();
assert!(err.contains("--yes"), "{err}");
}
#[test]
fn confirm_destructive_allows_local_and_explicit_yes() {
// Local needs no confirmation, even with --json.
assert!(confirm_destructive("cleanup", "file:///tmp/g.omni", false, true).is_ok());
assert!(confirm_destructive("branch delete", "graph.omni", false, true).is_ok());
// --yes consents to a non-local target.
assert!(confirm_destructive("cleanup", "s3://b/g.omni", true, true).is_ok());
}
// RFC-011 Decision 2: `--server` accepts a literal URL (value with `://`),
// bypassing the operator-config registry — so no config / OMNIGRAPH_HOME is
// read on this path (hermetic).

View file

@ -188,6 +188,10 @@ async fn main() -> Result<()> {
cli.store.as_deref(),
)?;
let branch = resolve_branch(&config, branch, None, "main");
if matches!(mode, CliLoadMode::Overwrite) {
confirm_destructive("load --mode overwrite", client.uri(), cli.yes, json)?;
}
echo_write_target(cli.quiet, "load", client.uri(), client.is_remote());
let payload = client
.load(&branch, from.as_deref(), &data.to_string_lossy(), mode)
.await?;
@ -223,6 +227,7 @@ async fn main() -> Result<()> {
)?;
let branch = resolve_branch(&config, branch, None, "main");
let from = resolve_branch(&config, from, None, "main");
echo_write_target(cli.quiet, "ingest", client.uri(), client.is_remote());
let payload = client
.ingest(&branch, &from, &data.to_string_lossy(), mode)
.await?;
@ -251,6 +256,7 @@ async fn main() -> Result<()> {
cli.store.as_deref(),
)?;
let from = resolve_branch(&config, from, None, "main");
echo_write_target(cli.quiet, "branch create", client.uri(), client.is_remote());
let payload = client.branch_create_from(&from, &name).await?;
if json {
print_json(&payload)?;
@ -297,6 +303,8 @@ async fn main() -> Result<()> {
cli.profile.as_deref(),
cli.store.as_deref(),
)?;
confirm_destructive("branch delete", client.uri(), cli.yes, json)?;
echo_write_target(cli.quiet, "branch delete", client.uri(), client.is_remote());
let payload = client.branch_delete(&name).await?;
if json {
print_json(&payload)?;
@ -322,6 +330,7 @@ async fn main() -> Result<()> {
cli.store.as_deref(),
)?;
let into = resolve_branch(&config, into, None, "main");
echo_write_target(cli.quiet, "branch merge", client.uri(), client.is_remote());
let payload = client.branch_merge(&source, &into).await?;
if json {
print_json(&payload)?;
@ -440,6 +449,7 @@ async fn main() -> Result<()> {
(!registry.is_empty()).then_some(registry)
};
let label = client.selected().unwrap_or(client.uri()).to_string();
echo_write_target(cli.quiet, "schema apply", client.uri(), client.is_remote());
let output = client
.apply_schema(&schema_source, allow_data_loss, |catalog| {
if let Some(registry) = registry.as_ref() {
@ -802,6 +812,7 @@ async fn main() -> Result<()> {
"optimize",
)
.await?;
echo_write_target(cli.quiet, "optimize", &uri, false);
let db = Omnigraph::open(&uri).await?;
let stats = db.optimize().await?;
if json {
@ -852,6 +863,7 @@ async fn main() -> Result<()> {
"repair",
)
.await?;
echo_write_target(cli.quiet, "repair", &uri, false);
let db = Omnigraph::open(&uri).await?;
let stats = db
.repair(omnigraph::db::RepairOptions { confirm, force })
@ -967,6 +979,11 @@ async fn main() -> Result<()> {
);
return Ok(());
}
// Past the preview gate: a real destructive run. Against a non-local
// scope this additionally requires --yes (or an interactive yes), so
// `cleanup --confirm s3://…` in CI refuses rather than destroying.
confirm_destructive("cleanup", &uri, cli.yes, json)?;
echo_write_target(cli.quiet, "cleanup", &uri, false);
let options = omnigraph::db::CleanupPolicyOptions {
keep_versions: keep,

View file

@ -1565,6 +1565,160 @@ fn branch_delete_rejects_main() {
assert!(stderr.contains("cannot delete branch 'main'"));
}
// ── RFC-011 Decision 9: write diagnostics + non-local destructive-confirm ──
#[test]
fn write_echoes_resolved_target_to_stderr() {
// Every write echoes its resolved target + access path to stderr; --json
// (stdout) is unaffected. A local load → "(direct, local)".
let temp = tempdir().unwrap();
let graph = graph_path(temp.path());
init_graph(&graph);
let data = fixture("test.jsonl");
let output = output_success(
cli()
.arg("load")
.arg("--mode")
.arg("append")
.arg("--data")
.arg(&data)
.arg(&graph)
.arg("--json"),
);
let stderr = String::from_utf8(output.stderr).unwrap();
assert!(
stderr.contains("omnigraph load →") && stderr.contains("(direct, local)"),
"missing write-target echo; stderr: {stderr}"
);
// stdout still parses as JSON — the echo went to stderr.
let _: Value = serde_json::from_slice(&output.stdout).unwrap();
}
#[test]
fn quiet_suppresses_the_write_target_echo() {
let temp = tempdir().unwrap();
let graph = graph_path(temp.path());
init_graph(&graph);
let data = fixture("test.jsonl");
let output = output_success(
cli()
.arg("--quiet")
.arg("load")
.arg("--mode")
.arg("append")
.arg("--data")
.arg(&data)
.arg(&graph),
);
let stderr = String::from_utf8(output.stderr).unwrap();
assert!(
!stderr.contains("omnigraph load →"),
"--quiet should suppress the echo; stderr: {stderr}"
);
}
#[test]
fn branch_delete_against_non_local_scope_refuses_without_yes() {
// No bucket needed: the confirm gate fires before the graph is opened.
let output = output_failure(
cli()
.arg("branch")
.arg("delete")
.arg("--store")
.arg("s3://fake-bucket/g.omni")
.arg("feature")
.arg("--json"),
);
let stderr = String::from_utf8(output.stderr).unwrap();
assert!(
stderr.contains("refusing destructive `branch delete`") && stderr.contains("--yes"),
"expected a non-local destructive refusal; stderr: {stderr}"
);
}
#[test]
fn branch_delete_against_non_local_scope_passes_gate_with_yes() {
// With --yes the gate is bypassed; the command then fails for an unrelated
// reason (the fake bucket can't be opened), so the refusal must be ABSENT.
let output = output_failure(
cli()
.arg("branch")
.arg("delete")
.arg("--store")
.arg("s3://fake-bucket/g.omni")
.arg("feature")
.arg("--yes")
.arg("--json"),
);
let stderr = String::from_utf8(output.stderr).unwrap();
assert!(
!stderr.contains("refusing destructive"),
"--yes should bypass the confirm gate; stderr: {stderr}"
);
}
#[test]
fn overwrite_load_against_non_local_scope_refuses_without_yes() {
let output = output_failure(
cli()
.arg("load")
.arg("--mode")
.arg("overwrite")
.arg("--data")
.arg(fixture("test.jsonl"))
.arg("--store")
.arg("s3://fake-bucket/g.omni")
.arg("--json"),
);
let stderr = String::from_utf8(output.stderr).unwrap();
assert!(
stderr.contains("refusing destructive `load --mode overwrite`"),
"expected a non-local overwrite refusal; stderr: {stderr}"
);
}
#[test]
fn cleanup_against_non_local_scope_refuses_without_yes() {
// Past the --confirm preview gate, a non-local cleanup still needs --yes.
let output = output_failure(
cli()
.arg("cleanup")
.arg("--store")
.arg("s3://fake-bucket/g.omni")
.arg("--keep")
.arg("5")
.arg("--confirm")
.arg("--json"),
);
let stderr = String::from_utf8(output.stderr).unwrap();
assert!(
stderr.contains("refusing destructive `cleanup`"),
"expected a non-local cleanup refusal; stderr: {stderr}"
);
}
#[test]
fn cleanup_against_local_scope_executes_with_confirm() {
// Local cleanup needs no --yes; --confirm alone executes (and echoes).
let temp = tempdir().unwrap();
let graph = graph_path(temp.path());
init_graph(&graph);
load_fixture(&graph);
let output = output_success(
cli()
.arg("cleanup")
.arg("--keep")
.arg("1")
.arg("--confirm")
.arg(&graph)
.arg("--json"),
);
let payload: Value = serde_json::from_slice(&output.stdout).unwrap();
assert!(payload["tables"].as_array().is_some(), "{payload}");
let stderr = String::from_utf8(output.stderr).unwrap();
assert!(stderr.contains("omnigraph cleanup →"), "stderr: {stderr}");
}
#[test]
fn branch_merge_defaults_target_to_main() {
let temp = tempdir().unwrap();

View file

@ -142,7 +142,10 @@ fn parity_branch_create_delete() {
let (l, r) = p.run(&["branch", "create", "--from", "main", "parity-branch", "--json"],
);
assert_parity("branch create", &l, &r);
let (l, r) = p.run(&["branch", "delete", "parity-branch", "--json"],
// `branch delete` is destructive: the served (remote) arm is non-local and
// requires consent (RFC-011 Decision 9), so the row passes `--yes` to test
// the operation itself, not the safety gate. The local arm ignores `--yes`.
let (l, r) = p.run(&["branch", "delete", "parity-branch", "--yes", "--json"],
);
assert_parity("branch delete", &l, &r);
}

View file

@ -550,6 +550,8 @@ fn remote_branch_delete_removes_branch() {
.arg("--config")
.arg(&config)
.arg("feature")
// Served target is non-local → destructive-confirm gate (RFC-011 D9).
.arg("--yes")
.arg("--json"),
));
assert_eq!(deleted["name"], "feature");