Sprint B.6: CLI guardrails — --dry-run, protected_repos, VULP_AGENT marker
- global --dry-run: sync/release/init/collab/license print 'DRY RUN: would...' and exit 0 (no auth needed for previews where possible) - config protected_repos (name/basename, case-insensitive): mutating commands exit 1 on listed repos unless --force; checked before dry-run - VULP_AGENT env: activity::log centrally appends '· via agent' to detail, covering both CLI and TUI writers - sync: unknown --action now rejected before touching any repo
This commit is contained in:
1 parent
f1432d1cd3
commit
8119cdeba7
8 files changed
+133
-14
No files matched your search
+14
@@ -23,6 +23,20 @@ vulp <command> [--json] # non-interactive (scripts/AI)
|
|||||||
- Mutating commands write to the same `~/.vulp/activity.log` as the TUI,
|
- Mutating commands write to the same `~/.vulp/activity.log` as the TUI,
|
||||||
so CLI actions appear in the TUI's Overview feed.
|
so CLI actions appear in the TUI's Overview feed.
|
||||||
|
|
||||||
|
## Guardrails (for AI agents especially)
|
||||||
|
|
||||||
|
- **`--dry-run`** (global flag): mutating commands (`sync`, `release`,
|
||||||
|
`init`, `repo collab`, `repo license`) print `DRY RUN: would ...` lines
|
||||||
|
and exit 0 without changing anything. Read commands ignore it.
|
||||||
|
- **`protected_repos`** (config, comma-separated in `vulp config`): mutating
|
||||||
|
commands refuse to touch listed repos (exit 1) unless **`--force`** is
|
||||||
|
passed. Matching is by display label or dir basename, case-insensitive.
|
||||||
|
The protection check runs before `--dry-run`, so a dry run also reveals it.
|
||||||
|
- **`VULP_AGENT`** (env var, any non-empty value): every activity-log entry
|
||||||
|
written while it's set gets a `· via agent` marker in its detail, so human
|
||||||
|
and agent actions are distinguishable in `vulp activity` and the TUI feed.
|
||||||
|
AI agents driving vulp should always set it.
|
||||||
|
|
||||||
## Workspace (local, no network)
|
## Workspace (local, no network)
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
+11
-1
@@ -55,7 +55,17 @@ fn log_path() -> PathBuf {
|
|||||||
/// the Activity tab's commit feed (sourced straight from git log) covers
|
/// the Activity tab's commit feed (sourced straight from git log) covers
|
||||||
/// separately and accurately regardless of how it happened.
|
/// separately and accurately regardless of how it happened.
|
||||||
pub fn log(entry: &ActivityEntry) {
|
pub fn log(entry: &ActivityEntry) {
|
||||||
let Ok(line) = serde_json::to_string(entry) else { return };
|
// Executor marker: AI agents set VULP_AGENT so their actions are
|
||||||
|
// distinguishable from the human's in the shared log.
|
||||||
|
let mut entry = entry.clone();
|
||||||
|
if std::env::var("VULP_AGENT").map(|v| !v.is_empty()).unwrap_or(false) {
|
||||||
|
entry.detail = if entry.detail.is_empty() {
|
||||||
|
"via agent".into()
|
||||||
|
} else {
|
||||||
|
format!("{} · via agent", entry.detail)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let Ok(line) = serde_json::to_string(&entry) else { return };
|
||||||
let path = log_path();
|
let path = log_path();
|
||||||
if let Some(parent) = path.parent() {
|
if let Some(parent) = path.parent() {
|
||||||
let _ = std::fs::create_dir_all(parent);
|
let _ = std::fs::create_dir_all(parent);
|
||||||
|
|||||||
+4
-1
@@ -22,6 +22,9 @@ pub async fn run(cli: Cli) -> Result<()> {
|
|||||||
Some(Commands::Auth(args)) => crate::auth::run(args, &cfg).await,
|
Some(Commands::Auth(args)) => crate::auth::run(args, &cfg).await,
|
||||||
|
|
||||||
// ── everything else: non-interactive CLI ──
|
// ── everything else: non-interactive CLI ──
|
||||||
Some(cmd) => crate::cli_cmds::run(cmd, cli.json, &cfg).await,
|
Some(cmd) => {
|
||||||
|
let guard = crate::cli_cmds::Guard { dry_run: cli.dry_run, force: cli.force };
|
||||||
|
crate::cli_cmds::run(cmd, cli.json, guard, &cfg).await
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -19,6 +19,13 @@ pub struct Cli {
|
|||||||
/// Machine-readable JSON output on stdout (list/status/stats/... commands)
|
/// Machine-readable JSON output on stdout (list/status/stats/... commands)
|
||||||
#[arg(long, global = true)]
|
#[arg(long, global = true)]
|
||||||
pub json: bool,
|
pub json: bool,
|
||||||
|
/// Print what a mutating command (sync/release/init/collab/license)
|
||||||
|
/// would do without doing it
|
||||||
|
#[arg(long, global = true)]
|
||||||
|
pub dry_run: bool,
|
||||||
|
/// Proceed on repos listed in config `protected_repos`
|
||||||
|
#[arg(long, global = true)]
|
||||||
|
pub force: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Subcommand)]
|
#[derive(Subcommand)]
|
||||||
|
|||||||
+36
-5
@@ -14,18 +14,26 @@ use crate::cli::Commands;
|
|||||||
use crate::config::AppConfig;
|
use crate::config::AppConfig;
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
|
|
||||||
|
/// Global guardrail flags (`--dry-run`, `--force`) threaded into the
|
||||||
|
/// mutating commands; read commands ignore them.
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
pub(crate) struct Guard {
|
||||||
|
pub dry_run: bool,
|
||||||
|
pub force: bool,
|
||||||
|
}
|
||||||
|
|
||||||
/// Dispatches one parsed subcommand. Only called for commands that aren't
|
/// Dispatches one parsed subcommand. Only called for commands that aren't
|
||||||
/// `Config`/`Auth`/the bare TUI (those keep their existing paths in `app.rs`).
|
/// `Config`/`Auth`/the bare TUI (those keep their existing paths in `app.rs`).
|
||||||
pub async fn run(cmd: Commands, json: bool, cfg: &AppConfig) -> Result<()> {
|
pub async fn run(cmd: Commands, json: bool, guard: Guard, cfg: &AppConfig) -> Result<()> {
|
||||||
match cmd {
|
match cmd {
|
||||||
Commands::List { dirty, behind, ahead, lang, project } =>
|
Commands::List { dirty, behind, ahead, lang, project } =>
|
||||||
workspace::list(cfg, json, dirty, behind, ahead, lang, project),
|
workspace::list(cfg, json, dirty, behind, ahead, lang, project),
|
||||||
Commands::Status { repo } => workspace::status(cfg, json, &repo),
|
Commands::Status { repo } => workspace::status(cfg, json, &repo),
|
||||||
Commands::Scan => workspace::scan_cmd(cfg, json),
|
Commands::Scan => workspace::scan_cmd(cfg, json),
|
||||||
Commands::Sync { repos, all, project, action, message } =>
|
Commands::Sync { repos, all, project, action, message } =>
|
||||||
workspace::sync(cfg, repos, all, project, &action, message),
|
workspace::sync(cfg, guard, repos, all, project, &action, message),
|
||||||
Commands::Release { repo, tag, notes } =>
|
Commands::Release { repo, tag, notes } =>
|
||||||
remote::release(cfg, &repo, &tag, ¬es).await,
|
remote::release(cfg, guard, &repo, &tag, ¬es).await,
|
||||||
Commands::Stats { period } => workspace::stats(cfg, json, &period),
|
Commands::Stats { period } => workspace::stats(cfg, json, &period),
|
||||||
Commands::Activity { limit, kind } => workspace::activity(json, limit, kind),
|
Commands::Activity { limit, kind } => workspace::activity(json, limit, kind),
|
||||||
|
|
||||||
@@ -34,9 +42,9 @@ pub async fn run(cmd: Commands, json: bool, cfg: &AppConfig) -> Result<()> {
|
|||||||
|
|
||||||
Commands::Pr { action } => remote::pr(cfg, json, action).await,
|
Commands::Pr { action } => remote::pr(cfg, json, action).await,
|
||||||
Commands::Issue { action } => remote::issue(cfg, json, action).await,
|
Commands::Issue { action } => remote::issue(cfg, json, action).await,
|
||||||
Commands::Repo { action } => remote::repo_cmd(cfg, json, action).await,
|
Commands::Repo { action } => remote::repo_cmd(cfg, json, guard, action).await,
|
||||||
Commands::Init { name, private, description } =>
|
Commands::Init { name, private, description } =>
|
||||||
remote::init(cfg, &name, private, &description).await,
|
remote::init(cfg, guard, &name, private, &description).await,
|
||||||
Commands::Accounts => remote::accounts(cfg, json),
|
Commands::Accounts => remote::accounts(cfg, json),
|
||||||
Commands::Refresh => remote::refresh(cfg, json).await,
|
Commands::Refresh => remote::refresh(cfg, json).await,
|
||||||
|
|
||||||
@@ -96,6 +104,29 @@ pub(crate) fn default_provider(cfg: &AppConfig) -> (crate::auth::Account, Box<dy
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether `r` is listed in config `protected_repos` (display name or dir
|
||||||
|
/// basename, case-insensitive).
|
||||||
|
pub(crate) fn is_protected(cfg: &AppConfig, r: &crate::status::RepoSummary) -> bool {
|
||||||
|
let name = r.name.to_lowercase();
|
||||||
|
let base = r.path.file_name().map(|b| b.to_string_lossy().to_lowercase()).unwrap_or_default();
|
||||||
|
cfg.protected_repos.iter().any(|p| {
|
||||||
|
let p = p.trim().to_lowercase();
|
||||||
|
p == name || p == base
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Exits 1 when any target is protected and `--force` wasn't passed.
|
||||||
|
pub(crate) fn guard_protected(cfg: &AppConfig, force: bool, targets: &[&crate::status::RepoSummary]) {
|
||||||
|
if force { return; }
|
||||||
|
let blocked: Vec<&str> = targets.iter()
|
||||||
|
.filter(|r| is_protected(cfg, r))
|
||||||
|
.map(|r| r.name.as_str())
|
||||||
|
.collect();
|
||||||
|
if !blocked.is_empty() {
|
||||||
|
die(1, &format!("protected repos: {} — pass --force to proceed", blocked.join(", ")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Logs one vulp-performed action to `~/.vulp/activity.log` (same log the
|
/// Logs one vulp-performed action to `~/.vulp/activity.log` (same log the
|
||||||
/// TUI writes, so CLI actions show up in the Overview feed too).
|
/// TUI writes, so CLI actions show up in the Overview feed too).
|
||||||
pub(crate) fn log_activity(kind: crate::activity::ActivityKind, repo: &str, detail: String) {
|
pub(crate) fn log_activity(kind: crate::activity::ActivityKind, repo: &str, detail: String) {
|
||||||
|
|||||||
+33
-6
@@ -78,8 +78,13 @@ pub async fn issue(cfg: &AppConfig, json: bool, cmd: IssueCmd) -> Result<()> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn release(cfg: &AppConfig, repo: &str, tag: &str, notes: &str) -> Result<()> {
|
pub async fn release(cfg: &AppConfig, guard: super::Guard, repo: &str, tag: &str, notes: &str) -> Result<()> {
|
||||||
let r = find_repo(cfg, repo);
|
let r = find_repo(cfg, repo);
|
||||||
|
super::guard_protected(cfg, guard.force, &[&r]);
|
||||||
|
if guard.dry_run {
|
||||||
|
println!("DRY RUN: would tag {} as {tag}, push the tag, and publish a provider release", r.name);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
let msg = if notes.is_empty() { tag } else { notes };
|
let msg = if notes.is_empty() { tag } else { notes };
|
||||||
git_run(&r.path, &["tag", "-a", tag, "-m", msg]);
|
git_run(&r.path, &["tag", "-a", tag, "-m", msg]);
|
||||||
if !git_run_ok(&r.path, &["push", "origin", tag]) {
|
if !git_run_ok(&r.path, &["push", "origin", tag]) {
|
||||||
@@ -103,7 +108,7 @@ pub async fn release(cfg: &AppConfig, repo: &str, tag: &str, notes: &str) -> Res
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn repo_cmd(cfg: &AppConfig, json: bool, cmd: RepoCmd) -> Result<()> {
|
pub async fn repo_cmd(cfg: &AppConfig, json: bool, guard: super::Guard, cmd: RepoCmd) -> Result<()> {
|
||||||
match cmd {
|
match cmd {
|
||||||
RepoCmd::Url { repo } => {
|
RepoCmd::Url { repo } => {
|
||||||
let r = find_repo(cfg, &repo);
|
let r = find_repo(cfg, &repo);
|
||||||
@@ -162,16 +167,21 @@ pub async fn repo_cmd(cfg: &AppConfig, json: bool, cmd: RepoCmd) -> Result<()> {
|
|||||||
die(2, "role must be pull | push | admin");
|
die(2, "role must be pull | push | admin");
|
||||||
}
|
}
|
||||||
let r = find_repo(cfg, &repo);
|
let r = find_repo(cfg, &repo);
|
||||||
let (_, provider) = default_provider(cfg);
|
super::guard_protected(cfg, guard.force, &[&r]);
|
||||||
let repo_basename = r.path.file_name().map(|b| b.to_string_lossy().to_string())
|
let repo_basename = r.path.file_name().map(|b| b.to_string_lossy().to_string())
|
||||||
.unwrap_or_else(|| r.name.clone());
|
.unwrap_or_else(|| r.name.clone());
|
||||||
|
if guard.dry_run {
|
||||||
|
println!("DRY RUN: would invite {user} to {repo_basename} ({role})");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let (_, provider) = default_provider(cfg);
|
||||||
match provider.add_collaborator(&repo_basename, &user, &role).await {
|
match provider.add_collaborator(&repo_basename, &user, &role).await {
|
||||||
Ok(()) => { println!("invited {user} to {repo_basename} ({role})"); Ok(()) }
|
Ok(()) => { println!("invited {user} to {repo_basename} ({role})"); Ok(()) }
|
||||||
Err(e) => die(1, &format!("invite failed: {e:#}")),
|
Err(e) => die(1, &format!("invite failed: {e:#}")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
RepoCmd::License { repos, all, kind, holder } => {
|
RepoCmd::License { repos, all, kind, holder } => {
|
||||||
license(cfg, json, repos, all, kind, holder)
|
license(cfg, json, guard, repos, all, kind, holder)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -179,7 +189,7 @@ pub async fn repo_cmd(cfg: &AppConfig, json: bool, cmd: RepoCmd) -> Result<()> {
|
|||||||
/// `vulp repo license` — writes a LICENSE into each target repo that lacks
|
/// `vulp repo license` — writes a LICENSE into each target repo that lacks
|
||||||
/// one. Explicit names that already have a license are reported as skipped,
|
/// one. Explicit names that already have a license are reported as skipped,
|
||||||
/// not errors; `--all` targets every unlicensed workspace repo.
|
/// not errors; `--all` targets every unlicensed workspace repo.
|
||||||
fn license(cfg: &AppConfig, json: bool, repos: Vec<String>, all: bool,
|
fn license(cfg: &AppConfig, json: bool, guard: super::Guard, repos: Vec<String>, all: bool,
|
||||||
kind: Option<String>, holder: Option<String>) -> Result<()> {
|
kind: Option<String>, holder: Option<String>) -> Result<()> {
|
||||||
use crate::license::{LicenseKind, WriteOutcome};
|
use crate::license::{LicenseKind, WriteOutcome};
|
||||||
|
|
||||||
@@ -211,6 +221,18 @@ fn license(cfg: &AppConfig, json: bool, repos: Vec<String>, all: bool,
|
|||||||
}).collect()
|
}).collect()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
super::guard_protected(cfg, guard.force, &targets);
|
||||||
|
if guard.dry_run {
|
||||||
|
for r in &targets {
|
||||||
|
if r.has_license {
|
||||||
|
println!("DRY RUN: would skip {} (already has a license)", r.name);
|
||||||
|
} else {
|
||||||
|
println!("DRY RUN: would write {} LICENSE to {} (holder: {holder})", kind.label(), r.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
let year = chrono::Datelike::year(&chrono::Utc::now());
|
let year = chrono::Datelike::year(&chrono::Utc::now());
|
||||||
let (mut written, mut skipped, mut failed) = (Vec::new(), Vec::new(), Vec::new());
|
let (mut written, mut skipped, mut failed) = (Vec::new(), Vec::new(), Vec::new());
|
||||||
for r in targets {
|
for r in targets {
|
||||||
@@ -240,11 +262,16 @@ fn license(cfg: &AppConfig, json: bool, repos: Vec<String>, all: bool,
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn init(cfg: &AppConfig, name: &str, private: bool, description: &str) -> Result<()> {
|
pub async fn init(cfg: &AppConfig, guard: super::Guard, name: &str, private: bool, description: &str) -> Result<()> {
|
||||||
let path = cfg.workspace_root.join(name);
|
let path = cfg.workspace_root.join(name);
|
||||||
if path.exists() {
|
if path.exists() {
|
||||||
die(1, &format!("{} already exists", path.display()));
|
die(1, &format!("{} already exists", path.display()));
|
||||||
}
|
}
|
||||||
|
if guard.dry_run {
|
||||||
|
println!("DRY RUN: would init {}, create a {} repo on the provider, and push",
|
||||||
|
path.display(), if private { "private" } else { "public" });
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
let (_, provider) = default_provider(cfg);
|
let (_, provider) = default_provider(cfg);
|
||||||
|
|
||||||
std::fs::create_dir_all(&path)?;
|
std::fs::create_dir_all(&path)?;
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ pub fn scan_cmd(cfg: &AppConfig, json: bool) -> Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn sync(
|
pub fn sync(
|
||||||
cfg: &AppConfig,
|
cfg: &AppConfig, guard: super::Guard,
|
||||||
repos: Vec<String>, all: bool, project: Option<String>,
|
repos: Vec<String>, all: bool, project: Option<String>,
|
||||||
action: &str, message: Option<String>,
|
action: &str, message: Option<String>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
@@ -120,11 +120,25 @@ pub fn sync(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let action = action.replace('-', "_"); // commit-push → commit_push
|
let action = action.replace('-', "_"); // commit-push → commit_push
|
||||||
|
if !["pull", "push", "commit_push"].contains(&action.as_str()) {
|
||||||
|
die(2, &format!("unknown action '{action}' (pull | push | commit-push)"));
|
||||||
|
}
|
||||||
if action == "commit_push" && message.is_none() {
|
if action == "commit_push" && message.is_none() {
|
||||||
die(2, "--message is required for commit-push");
|
die(2, "--message is required for commit-push");
|
||||||
}
|
}
|
||||||
let msg = message.unwrap_or_default();
|
let msg = message.unwrap_or_default();
|
||||||
|
|
||||||
|
super::guard_protected(cfg, guard.force, &targets);
|
||||||
|
if guard.dry_run {
|
||||||
|
for r in &targets {
|
||||||
|
match action.as_str() {
|
||||||
|
"commit_push" => println!("DRY RUN: would commit+push {} (message: {msg})", r.name),
|
||||||
|
a => println!("DRY RUN: would {a} {}", r.name),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
let mut failed = 0usize;
|
let mut failed = 0usize;
|
||||||
for r in &targets {
|
for r in &targets {
|
||||||
eprintln!("── {} [{}]", r.name, action);
|
eprintln!("── {} [{}]", r.name, action);
|
||||||
|
|||||||
@@ -44,6 +44,10 @@ pub struct AppConfig {
|
|||||||
/// Default template for the add-LICENSE action: "mit" or "apache-2.0".
|
/// Default template for the add-LICENSE action: "mit" or "apache-2.0".
|
||||||
#[serde(default = "default_license_kind")]
|
#[serde(default = "default_license_kind")]
|
||||||
pub default_license: String,
|
pub default_license: String,
|
||||||
|
/// Repos (display name or dir basename, case-insensitive) that mutating
|
||||||
|
/// CLI commands refuse to touch unless `--force` is passed.
|
||||||
|
#[serde(default)]
|
||||||
|
pub protected_repos: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_license_kind() -> String { "mit".into() }
|
fn default_license_kind() -> String { "mit".into() }
|
||||||
@@ -82,6 +86,7 @@ impl Default for AppConfig {
|
|||||||
show_locs: false,
|
show_locs: false,
|
||||||
license_holder: String::new(),
|
license_holder: String::new(),
|
||||||
default_license: default_license_kind(),
|
default_license: default_license_kind(),
|
||||||
|
protected_repos: vec![],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -107,6 +112,7 @@ impl AppConfig {
|
|||||||
("workspace_groups", self.workspace_groups.join(", ")),
|
("workspace_groups", self.workspace_groups.join(", ")),
|
||||||
("license_holder", self.license_holder.clone()),
|
("license_holder", self.license_holder.clone()),
|
||||||
("default_license", self.default_license.clone()),
|
("default_license", self.default_license.clone()),
|
||||||
|
("protected_repos", self.protected_repos.join(", ")),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,6 +136,13 @@ impl AppConfig {
|
|||||||
"vsc_data_dir" => self.vsc_data_dir = PathBuf::from(&value),
|
"vsc_data_dir" => self.vsc_data_dir = PathBuf::from(&value),
|
||||||
"license_holder" => self.license_holder = value,
|
"license_holder" => self.license_holder = value,
|
||||||
"default_license" => self.default_license = value,
|
"default_license" => self.default_license = value,
|
||||||
|
"protected_repos" => {
|
||||||
|
self.protected_repos = value
|
||||||
|
.split(',')
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.collect();
|
||||||
|
}
|
||||||
"workspace_groups" => {
|
"workspace_groups" => {
|
||||||
self.workspace_groups = value
|
self.workspace_groups = value
|
||||||
.split(',')
|
.split(',')
|
||||||
|
|||||||
Reference in new issue
Block a user