Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 36 additions & 39 deletions crates/socket-patch-cli/CLI_CONTRACT.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion crates/socket-patch-cli/src/commands/apply.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3249,7 +3249,7 @@ const LOCKFILE_ONLY_DETAIL: &str =
/// this host — an `os`/`cpu`-gated optional dependency (`fsevents`,
/// `@esbuild/<os>-<cpu>`), a devDependency under `npm ci --omit=dev`. The
/// tree is in its correct end state, so they are calm skips, as `scan
/// --apply` treats lockfile-only packages. Global runs have no project
/// --mode agent` treats lockfile-only packages. Global runs have no project
/// lock, so nothing is lockfile-resolved there.
async fn lockfile_resolved(common: &GlobalArgs, unmatched: &[String]) -> HashSet<String> {
if unmatched.is_empty() || common.is_global() {
Expand Down
1 change: 0 additions & 1 deletion crates/socket-patch-cli/src/commands/get.rs
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,6 @@ pub struct GetArgs {
// exported-but-empty `SOCKET_SAVE_ONLY=`) would abort every `get`.
#[arg(
long = "save-only",
alias = "no-apply",
env = "SOCKET_SAVE_ONLY",
default_value_t = false,
value_parser = crate::args::parse_bool_flag,
Expand Down
2 changes: 1 addition & 1 deletion crates/socket-patch-cli/src/commands/scan/discovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ pub(crate) struct LedgerSupplement {

/// Vendored-ledger packages with no crawled counterpart: on a fresh clone
/// the committed artifact IS the dependency, so these stay discoverable
/// (updates[] detection, the table, and `scan --vendor` re-vendor/in-sync
/// (updates[] detection, the table, and `scan --mode vendored` re-vendor/in-sync
/// runs all keep working before any install). They are NOT "lockfile-only"
/// — nothing needs installing; the artifact satisfies the lock. `state` is
/// the ledger `run` already loaded (`vendor::load_state`).
Expand Down
2 changes: 1 addition & 1 deletion crates/socket-patch-cli/src/commands/scan/hosted.rs
Original file line number Diff line number Diff line change
Expand Up @@ -553,7 +553,7 @@ pub(super) async fn run_redirect(
batch_failed: bool,
stage: &mut super::rollout::Stage,
) -> i32 {
// Same discovery/selection as `--apply`/`--vendor`.
// Same discovery/selection as agent and vendored mode.
let discovered = match discover_selected(
api_client,
all_packages_with_patches,
Expand Down
82 changes: 23 additions & 59 deletions crates/socket-patch-cli/src/commands/scan/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -149,8 +149,7 @@ fn batch_chunks(purls: &[String], batch_size: usize, max_body_bytes: usize) -> V
}

/// The three patch-application modes `scan` can drive, selectable via
/// `--mode`. Vendored and agent also keep a hidden deprecated boolean
/// spelling (`--vendor`, `--apply`/`--sync`).
/// `--mode`. `--sync` is shorthand for `--mode agent --prune`.
//
// The `///` docs on the variants are user-facing `--help` text (shared
// with `get --mode`); keep implementation notes in `//` comments.
Expand All @@ -162,12 +161,10 @@ pub enum ScanMode {
Hosted,
/// Commit patched artifacts to `.socket/vendor/`: hermetic,
/// offline-safe installs at the cost of repo size
// Equivalent to `--vendor`.
Vendored,
/// Record patches in `.socket/manifest.json` plus blobs and re-apply
/// them in place (e.g. from CI): smallest repo footprint, but every
/// install environment must run the agent
// Equivalent to `--apply`.
Agent,
}

Expand All @@ -183,53 +180,34 @@ impl ScanMode {
}
}

/// Fold the boolean spellings (`--vendor` / `--apply` / `--sync`) into
/// `args.mode`, so `ScanMode` is the single
/// source of truth everything downstream reads (the booleans are input
/// spellings only, never consulted after this returns), and enforce the
/// Resolve `args.mode` from `--mode` and `--sync`, so `ScanMode` is the
/// single source of truth everything downstream reads, and enforce the
/// cross-flag rules clap cannot express:
///
/// * `--mode X` combined with a boolean belonging to a DIFFERENT mode is a
/// contradiction → `Err`. Clap's `conflicts_with` is value-independent —
/// it could not allow `--mode vendored --vendor` while rejecting
/// `--mode hosted --vendor` — so the check lives here.
/// * The same mode spelled both ways (`--mode vendored --vendor`) is
/// redundant but accepted: both spellings mean one thing.
/// * `--sync` implies `--apply`, so it counts as an agent-mode spelling;
/// `--prune` is an orthogonal GC knob and never conflicts. (`--sync`'s
/// * `--sync` means `--mode agent --prune`, so `--mode X --sync` with any
/// mode other than agent is a contradiction → `Err`. Clap's
/// `conflicts_with` is value-independent — it could not allow
/// `--mode agent --sync` while rejecting `--mode hosted --sync` — so the
/// check lives here. `--mode agent --sync` is redundant but accepted.
/// * `--prune` is an orthogonal GC knob and never conflicts. (`--sync`'s
/// prune half is orthogonal too, and stays a separate read in `run`.)
/// Hosted mode runs no GC, so `--mode hosted --prune` stays accepted but
/// emits an explicit `redirect_prune_ignored` warning in `run` rather
/// than silently dropping the flag.
///
/// Public (not `pub(crate)`) so the CLI-contract tests can exercise the
/// fold without driving a full `run()`.
/// resolution without driving a full `run()`.
pub fn resolve_mode_flags(args: &mut ScanArgs) -> Result<(), String> {
if let Some(mode) = args.mode {
// First boolean that selects a mode OTHER than the requested one.
let mut conflicting: Option<&'static str> = None;
if args.vendor && mode != ScanMode::Vendored {
conflicting = Some("--vendor");
}
if args.apply && mode != ScanMode::Agent {
conflicting = Some("--apply");
}
if args.sync && mode != ScanMode::Agent {
conflicting = Some("--sync");
}
if let Some(flag) = conflicting {
// "cannot be used with" phrasing matches clap's conflict errors —
// the scan_vendor_e2e contract test accepts exactly that shape.
return Err(format!(
"--mode {} cannot be used with {flag}: the flags select different \
modes (--vendor means --mode vendored; --apply and --sync mean \
--mode agent)",
"--mode {} cannot be used with --sync: --sync means --mode agent --prune",
mode.cli_name(),
));
}
} else if args.vendor {
args.mode = Some(ScanMode::Vendored);
} else if args.apply || args.sync {
} else if args.sync {
args.mode = Some(ScanMode::Agent);
} else if !args.prune && !args.common.is_global() {
// v5: hosted is the default. A `--prune` or global scan with no mode
Expand Down Expand Up @@ -266,11 +244,6 @@ pub struct ScanArgs {
#[arg(long = "batch-size", env = "SOCKET_BATCH_SIZE")]
pub batch_size: Option<usize>,

// Hidden, deprecated spelling of `--mode agent`: download the selected
// patches and apply them in place.
#[arg(long, default_value_t = false, hide = true)]
pub apply: bool,

/// Garbage-collect after the scan: prune manifest entries for
/// packages that are no longer installed, then delete orphan blob,
/// diff and package-archive files from `.socket/`. Off by default so
Expand All @@ -286,19 +259,10 @@ pub struct ScanArgs {
#[arg(long, default_value_t = false)]
pub sync: bool,

// Hidden, deprecated spelling of `--mode vendored`: vendor every
// patched dependency the scan selects into the committable
// `.socket/vendor/` tree instead of applying patches in place.
#[arg(long, default_value_t = false, hide = true, conflicts_with_all = ["apply", "sync"])]
pub vendor: bool,

/// How discovered patches are consumed [default: hosted]. A `--prune`
/// or `--global` scan with no mode only reports
// The hidden `--vendor` and `--apply` are deprecated spellings of
// `--mode vendored` and `--mode agent` (`--sync` also selects agent);
// hosted has no boolean spelling. Combining `--mode` with a boolean
// from a DIFFERENT mode is rejected in `resolve_mode_flags`; the same
// mode spelled both ways is accepted.
// `--sync` also selects agent; combining it with a different `--mode`
// is rejected in `resolve_mode_flags`.
#[arg(long = "mode", value_enum)]
pub mode: Option<ScanMode>,

Expand Down Expand Up @@ -326,7 +290,7 @@ pub struct ScanArgs {
/// On a successful scan, also generate an OpenVEX 0.2.0 document.
/// `--vex <path>` is the trigger; the `--vex-*` knobs mirror the
/// standalone `vex` command. The document is built from the manifest
/// as it stands after the scan (including any `--apply`/`--sync`
/// as it stands after the scan (including any `--mode agent`/`--sync`
/// writes) and verified against on-disk state. A requested-but-failed
/// VEX makes the command exit non-zero.
#[command(flatten)]
Expand Down Expand Up @@ -776,8 +740,8 @@ fn emit_discovery_error_json(result: &mut serde_json::Value, message: &str) {
/// owned purls leave first (any uuid: the committed artifact IS the patch,
/// and a manifest moved past the vendored uuid would break VEX verification
/// until a vendor run refreshes the artifact — a newer patch still surfaces
/// in `updates[]`, the operator's signal to run `scan --vendor`), then
/// lockfile-only purls (nothing installed to patch in place; `scan --vendor`
/// in `updates[]`, the operator's signal to run `scan --mode vendored`), then
/// lockfile-only purls (nothing installed to patch in place; `scan --mode vendored`
/// fetches them pristine). Both classes become calm `skipped` records —
/// never an error.
struct AgentSelection {
Expand Down Expand Up @@ -864,7 +828,7 @@ fn download_params(args: &ScanArgs, save_only: bool, json: bool, silent: bool) -

/// The run-level context the agent engine borrows from scan: the client
/// `run` already built (proxy fallback included) and the flags the nested
/// apply inherits — so `scan --apply` honors `--lock-timeout` and never
/// apply inherits — so `scan --mode agent` honors `--lock-timeout` and never
/// rebuilds the client.
fn download_run<'a>(args: &ScanArgs, api_client: &'a ApiClient) -> DownloadRun<'a> {
DownloadRun {
Expand Down Expand Up @@ -1676,9 +1640,9 @@ async fn run_scan(
) -> i32 {
apply_env_toggles(&args.common);

// Fold the legacy mode booleans into `args.mode` (see
// `resolve_mode_flags`). Cross-mode combinations are usage errors
// (exit 2); under --json they print the coded error on stdout.
// Resolve `--mode`/`--sync` into `args.mode` (see
// `resolve_mode_flags`). `--sync` with another mode is a usage error
// (exit 2); under --json it prints the coded error on stdout.
if let Err(message) = resolve_mode_flags(&mut args) {
// The global-install refusal is the one with its own code: it is
// exactly `global_mode_conflict`'s message for the folded mode.
Expand Down Expand Up @@ -2642,7 +2606,7 @@ async fn run_scan(
}
push_scan_json_warning(&mut result, HOSTED_WIRING_RETAINED, &detail);
}
// --- Vendor path (if requested; conflicts with --apply/--sync) ---
// --- Vendor path (if requested; --sync selects agent instead) ---
} else if vendor {
// Must STAY a boxed fn: this branch's temporaries would otherwise
// live in the enclosing poll frame in debug builds, which has to
Expand Down Expand Up @@ -2672,7 +2636,7 @@ async fn run_scan(
}

// The GC and the VEX build below can write to stderr; the report-
// only arm has not flushed the scan event yet (the `--apply` arm
// only arm has not flushed the scan event yet (the agent arm
// did, in `discover_selected`).
telemetry.flush().await;

Expand Down
12 changes: 6 additions & 6 deletions crates/socket-patch-cli/src/commands/scan/vendor_flow.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
//! The vendored-mode (`--mode vendored` / `--vendor`) flow driven by
//! The vendored-mode (`--mode vendored`) flow driven by
//! `scan`: the shared download → vendor-engine → GC step, its JSON and
//! interactive arms, the pre-download skip partitions, and the `boxed_*`
//! transient-frame constructors that keep the never-taken vendor branches
Expand Down Expand Up @@ -66,7 +66,7 @@ type VendorStepError = (&'static str, String, Option<Box<Envelope>>);
/// [`VendorStepError`].
type VendorStepResult = Result<(bool, Envelope), VendorStepError>;

/// Dry-run preview for `scan --vendor` (and `get … --mode vendored
/// Dry-run preview for `scan --mode vendored` (and `get … --mode vendored
/// --dry-run`): classify each selected patch against the vendor ledger
/// without writing anything or touching the network beyond discovery.
/// Action values are part of the CLI contract: `would_vendor` (no ledger
Expand Down Expand Up @@ -523,7 +523,7 @@ async fn migrate_legacy_manifest_records(
}
}

/// The `scan --vendor` JSON path: discovery → (dry-run preview | download
/// The `scan --mode vendored` JSON path: discovery → (dry-run preview | download
/// → vendor engine → GC → embedded VEX) → print `result` → exit code.
/// The dry-run arm skips the VEX embed (emitting a `vex.skipped` marker
/// instead): a dry run vendors nothing, so there is no state to attest.
Expand Down Expand Up @@ -556,7 +556,7 @@ async fn run_vendor_json_path(
// The npm half of scan's crawl, for the vendor engine to reuse.
prior: Option<&NpmCrawlSnapshot>,
) -> i32 {
// Same discovery as `--apply`. Vendored purls are NOT filtered here —
// Same discovery as agent mode. Vendored purls are NOT filtered here —
// re-vendoring a stale uuid is the point of the flag (same-uuid re-runs
// land on the backend's `already_vendored` skip).
let discovered = match discover_selected(
Expand Down Expand Up @@ -600,7 +600,7 @@ async fn run_vendor_json_path(

if args.common.dry_run {
// No downloads, no backends: classify against the ledger
// and preview the GC, exactly like `--apply`'s dry run.
// and preview the GC, exactly like agent mode's dry run.
let takeover = crate::commands::vendor::gem_takeover_preview_refusals(
&args.common,
selected.iter().map(|p| p.purl.as_str()),
Expand Down Expand Up @@ -709,7 +709,7 @@ async fn run_vendor_json_path(
final_code
}

/// The `scan --vendor` interactive arm: download → vendor engine → GC,
/// The `scan --mode vendored` interactive arm: download → vendor engine → GC,
/// with human-readable output. `prefetched` holds the views the pre-download
/// baseline check already fetched (uuid-keyed), so the download phase
/// serves those records from memory. Extracted + boxed for the same
Expand Down
2 changes: 0 additions & 2 deletions crates/socket-patch-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,6 @@ pub enum Commands {
Scan(commands::scan::ScanArgs),

/// Patch one package, CVE, GHSA or patch UUID (hosted mode by default)
#[command(visible_alias = "download")]
Get(commands::get::GetArgs),

/// List the patches in this project: hosted and vendored lockfile
Expand Down Expand Up @@ -107,7 +106,6 @@ pub enum Commands {
/// Restores missing blobs and diff/package archives, rebuilds missing
/// or corrupt vendored artifacts, then deletes the artifacts nothing
/// references.
#[command(visible_alias = "gc")]
Repair(commands::repair::RepairArgs),

// Internal parse target of the root `--update` flag (see the rewrite
Expand Down
2 changes: 1 addition & 1 deletion crates/socket-patch-cli/tests/apply/lockfile_only_skip.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
//!
//! The tree is in its correct end state, so such a purl is a calm
//! `skipped`/`package_not_installed` that never fails the run — the same
//! treatment `scan --apply` gives lockfile-only packages. A purl with NO
//! treatment `scan --mode agent` gives lockfile-only packages. A purl with NO
//! lock evidence still fails the all-miss run (the wrong-`--cwd` guard).

use std::path::Path;
Expand Down
4 changes: 1 addition & 3 deletions crates/socket-patch-cli/tests/cli/output_modes_e2e.rs
Original file line number Diff line number Diff line change
Expand Up @@ -693,7 +693,7 @@ fn bare_uuid_fallback_treats_uuid_as_get_identifier() {
fn each_subcommand_help_prints_usage() {
let tmp = tempfile::tempdir().unwrap();
let subcommands = [
"apply", "rollback", "get", "scan", "list", "remove", "repair", "gc",
"apply", "rollback", "get", "scan", "list", "remove", "repair",
];
for sub in subcommands {
let (code, stdout, _stderr) = common::run_with_env(tmp.path(), &[sub, "--help"], &[]);
Expand All @@ -718,8 +718,6 @@ fn top_level_help_prints_all_subcommands() {
"top-level help missing {sub}; got: {stdout}"
);
}
// `gc` is the visible alias.
assert!(stdout.contains("gc"), "top-level help missing `gc` alias");
}

#[test]
Expand Down
Loading
Loading