Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
278a387
refactor(core): consolidate guarded file reads and drop redundant stats
mikolalysenko Sep 22, 2026
225bcd4
fix(lock): apply.lock never outlives the command that holds it (D1)
mikolalysenko Sep 22, 2026
74b7963
refactor(core): shared .socket prune helper, ledger dedupe, sweep hyg…
mikolalysenko Sep 22, 2026
9987625
refactor(core/redirect): shared guarded atomic staging, override gati…
mikolalysenko Sep 22, 2026
57db0ed
core(patch): harden blob/archive inputs, single write site, rollback …
mikolalysenko Sep 22, 2026
2ea5ad3
refactor(core/vendor): drift-keep parity, pre-write guards, husk prun…
mikolalysenko Sep 22, 2026
350f651
refactor(core): cache plain client, one org route builder, setup/upda…
mikolalysenko Sep 22, 2026
fb68c53
fix(lock): classify a vanished-parent EINVAL open unconditionally, wi…
mikolalysenko Sep 22, 2026
4efdc8c
scan(cli): report-only mode-less scan, hosted human parity, single le…
mikolalysenko Sep 22, 2026
60f05b0
cli(vendor,repair_vendor): D2 no-op wording, detached GC leg, lock or…
mikolalysenko Sep 22, 2026
0e74dc7
docs(contract/readme/changelog): pin the v5 .socket hygiene contract;…
mikolalysenko Sep 22, 2026
f09f643
refactor(core): land the core-integration handoffs, restore the VITES…
mikolalysenko Sep 22, 2026
ed73869
cli(scan): vendored mode is manifest-free (D2), GC lock hygiene
mikolalysenko Sep 22, 2026
8ea9a9c
cli(scan/hosted): D1 apply lock, takeover symlink pre-check, one cand…
mikolalysenko Sep 22, 2026
f966776
refactor(cli/get): manifest-free vendored get, one fetch loop, locked…
mikolalysenko Sep 23, 2026
201e66c
refactor(cli/rollback): lock-guard cleanup, one vendored-revert loop,…
mikolalysenko Sep 22, 2026
dd80648
refactor(cli-misc): ledger-aware list/setup --check, apply hook hygie…
mikolalysenko Sep 22, 2026
80065cd
test(cli/repair-vendor): pin the manifest-free vendored footprint (D2)
mikolalysenko Sep 23, 2026
e441157
fix(cli/scan+get): vendor-step staging error carries the demoted enve…
mikolalysenko Sep 23, 2026
0a6e388
test(cli/apply): re-pin no-manifest tests to the v5.0 manifest-naming…
mikolalysenko Sep 23, 2026
2a9d5b6
refactor(cli/vendored): one client + one lock window per vendored run…
mikolalysenko Sep 23, 2026
2281c0f
refactor(cli/scan+hosted): one ledger load per hosted run, one record…
mikolalysenko Sep 23, 2026
89ef1d7
refactor(cli/apply+get+misc): one lock window for download → manifest…
mikolalysenko Sep 23, 2026
a6f6245
refactor(cli/rollback+remove+repair+gc): one artifact sweep, recorded…
mikolalysenko Sep 23, 2026
cae588c
refactor(core/api+cli): drop the constant org_slug parameter from the…
mikolalysenko Sep 23, 2026
cb2c346
docs(contract/readme/changelog): reconcile the v5 docs with the lande…
mikolalysenko Sep 23, 2026
0ff02ed
chore(repo): untrack the committed backtest __pycache__ and gitignore…
mikolalysenko Sep 23, 2026
1d14d2d
style(cli+core): rustfmt the files this branch touched; retire the la…
mikolalysenko Sep 23, 2026
16ee176
fix(core/apply_lock): gate the drop-time unlink on inode identity; ma…
mikolalysenko Sep 23, 2026
a0c5ec3
fix(core+cli): prune the .socket/ husks three removal paths still lef…
mikolalysenko Sep 23, 2026
509d1f7
fix(core/patch): carry a pnpm store copy's ownership advisory; gate b…
mikolalysenko Sep 23, 2026
a43a98a
fix(core/vendor): size-gate the jar/nupkg in-sync probe before readin…
mikolalysenko Sep 23, 2026
be18cad
fix(cli/scan hosted): never quarantine lock-free; report a malformed …
mikolalysenko Sep 23, 2026
a1d9c9b
refactor(cli/gc): delete the dead self-locking run_vendor_gc wrapper;…
mikolalysenko Sep 23, 2026
acae3e4
fix(cli/get+scan vendored): unwind orphan blobs, refuse before the le…
mikolalysenko Sep 23, 2026
1016959
test(cli): pin the G6 footprint after vendored get/repair and the loc…
mikolalysenko Sep 23, 2026
c1307a2
fix(cli/setup+vex): surface an unreadable vendor ledger on manifest-f…
mikolalysenko Sep 23, 2026
f417eca
refactor(cli): hand the run's API client to the memory stager; docs f…
mikolalysenko Sep 23, 2026
d05babb
fix(core/vendor): drift-keep parity for composer, maven and nuget — k…
mikolalysenko Sep 23, 2026
5f26b47
refactor(cli/vendor): hand the caller's one ledger load to the vendor…
mikolalysenko Sep 23, 2026
e1c8cb6
docs(cli/tests): point the update-count header at download_and_apply_…
mikolalysenko Sep 23, 2026
cfa1fa0
fix(lock): let a Windows delete-pending window ride out the caller's …
mikolalysenko Sep 23, 2026
db0d74e
refactor(cli): name the argv parser for what it does, not one of its …
mikolalysenko Sep 23, 2026
62d928d
test(cli): retry ETXTBSY when exec'ing a just-written binary
mikolalysenko Sep 23, 2026
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
10 changes: 6 additions & 4 deletions .github/workflows/bun-compatibility.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@ name: Bun patch compatibility
# Native Bun installer matrix: builds the CLI once per OS, downloads each
# pinned Bun release straight from its GitHub release (retried, SHA-256
# verified against the release's SHASUMS256.txt) and runs
# `scripts/backtest-bun.py` — hosted, vendored and vendored-detached mode
# against the public minimist free patch, verifying the INSTALLED bytes,
# `scripts/backtest-bun.py` — hosted and vendored mode (vendored is
# manifest-free: the ledger embeds the record, so the former
# vendored-detached leg collapsed into it) against the public minimist free
# patch, verifying the INSTALLED bytes,
# lock stability, digest rejection and rollback on Linux, macOS and Windows.
# No Socket API token is needed. See docs/testing/bun-compatibility.md.
#
Expand Down Expand Up @@ -74,7 +76,7 @@ on:
required: false
default: ''
modes:
description: 'Space-separated modes from hosted / vendored / vendored-detached (empty = all three)'
description: 'Space-separated modes from hosted / vendored (empty = both)'
required: false
default: ''

Expand Down Expand Up @@ -308,7 +310,7 @@ jobs:
chmod +x native-cli/socket-patch* || true
cli="native-cli/socket-patch"
if [ "$RUNNER_OS" = "Windows" ]; then cli="native-cli/socket-patch.exe"; fi
modes="hosted vendored vendored-detached"
modes="hosted vendored"
if [ -n "$MODES_OVERRIDE" ]; then modes="$MODES_OVERRIDE"; fi
shapes_arg=()
if [ -n "$SHAPES_OVERRIDE" ]; then shapes_arg=(--shapes $SHAPES_OVERRIDE); fi
Expand Down
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -157,4 +157,7 @@ pypi/socket-patch/README.md

# Generated by scripts/study-crates.ts
study-output/
simplify-output/
simplify-output/
# Python bytecode caches (scripts/backtest-*.py)
__pycache__/
*.pyc
237 changes: 229 additions & 8 deletions CHANGELOG.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ tar = "=0.4.46"
flate2 = "=1.1.9"
zip = { version = "=8.6.0", default-features = false, features = ["deflate"] }
fs2 = "=0.4.3"
same-file = "=1.0.6"
libc = "=0.2.182"
semver = "=1.0.27"
self-replace = "=1.5.0"
Expand Down
136 changes: 76 additions & 60 deletions README.md

Large diffs are not rendered by default.

190 changes: 124 additions & 66 deletions crates/socket-patch-cli/CLI_CONTRACT.md

Large diffs are not rendered by default.

270 changes: 263 additions & 7 deletions crates/socket-patch-cli/src/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,12 @@ use std::path::{Path, PathBuf};

use clap::Args;

use socket_patch_core::api::client::ApiClientEnvOverrides;
use socket_patch_core::api::client::{
resolve_ambient_credentials, ApiClient, ApiClientEnvOverrides,
};
use socket_patch_core::constants::DEFAULT_PATCH_MANIFEST_PATH;
use socket_patch_core::crawlers::Ecosystem;
use socket_patch_core::vendor::VendorSource;
use socket_patch_core::vendor::{VendorServiceConfig, VendorSource};

/// clap value-parser for each `--ecosystems` / `SOCKET_ECOSYSTEMS` token.
///
Expand Down Expand Up @@ -258,8 +260,10 @@ pub struct GlobalArgs {
/// positive value retries with a 100 ms backoff until the lock
/// frees or the budget elapses. Only meaningful for the lock-
/// contending subcommands (`apply`, `rollback`, `repair`, `remove`,
/// `vendor`, and the vendored modes of `scan`/`get`); other
/// commands accept it silently.
/// `vendor`, `setup --exclude`'s manifest write, and the hosted /
/// vendored modes of `scan`/`get`); other commands accept it
/// silently. Every holder removes the lock file on exit, so a
/// leftover from a crashed run never contends.
#[arg(long = "lock-timeout", env = "SOCKET_LOCK_TIMEOUT")]
pub lock_timeout: Option<u64>,

Expand Down Expand Up @@ -308,6 +312,43 @@ impl GlobalArgs {
}
}

/// The project root whose `.socket/` state stores — manifest, vendor
/// ledger, redirect ledger — belong together: the RESOLVED manifest's
/// directory, stepping out of a standard `.socket/` layout when the
/// manifest lives in one. For the default `<cwd>/.socket/manifest.json`
/// this is exactly `cwd`; for a `--manifest-path` into another project
/// it is that project's root (its `.socket` parent's parent); for a
/// bare file like `--manifest-path /tmp/x/abs.json` it is the file's
/// own directory. Every command that reads more than one store must
/// derive them from THIS root, so `--manifest-path` can never
/// interleave two projects' state (CLI_CONTRACT.md: both stores always
/// come from the SAME project).
pub(crate) fn project_root(&self) -> PathBuf {
let manifest_path = self.resolved_manifest_path();
match manifest_path.parent() {
Some(dir)
if dir.file_name()
== Some(std::ffi::OsStr::new(
socket_patch_core::constants::SOCKET_DIR,
)) =>
{
dir.parent()
.map(Path::to_path_buf)
.unwrap_or_else(|| self.cwd.clone())
}
Some(dir) => dir.to_path_buf(),
None => self.cwd.clone(),
}
}

/// The directory the manifest lives in — where `apply.lock`, `blobs/`,
/// `diffs/` and `packages/` sit (`<cwd>/.socket` by default). The one
/// derivation every lock acquire and artifact probe uses; see
/// [`socket_dir_of`] for callers holding a raw manifest path.
pub(crate) fn socket_dir(&self) -> PathBuf {
socket_dir_of(&self.resolved_manifest_path(), &self.cwd)
}

/// Build [`ApiClientEnvOverrides`] from the CLI flags.
///
/// Every field is forwarded as `Some(_)` only when set and non-empty.
Expand All @@ -324,6 +365,57 @@ impl GlobalArgs {
proxy_url: self.proxy_url.clone().filter(|s| !s.is_empty()),
}
}

/// The `(api_token, org_slug)` telemetry is attributed with, resolved
/// through the API client's own credential chain (flag → the
/// `SOCKET_NO_API_TOKEN` veto → env → `socket login` config) WITHOUT
/// building a client. For the purely local commands (`list`, `setup`,
/// `vex`): a client would add the org-slug auto-resolve round-trip and
/// the "No SOCKET_API_TOKEN set" advisory to a command that needs
/// neither, while anything less than the full chain reported a
/// `socket login`-only caller's events anonymously to the public proxy
/// — off the on-prem host every other command reports to.
pub(crate) fn telemetry_credentials(&self) -> (Option<String>, Option<String>) {
let overrides = self.api_client_overrides();
resolve_ambient_credentials(overrides.api_token, overrides.org_slug)
}

/// The vendoring-service config every vendor entry point (`vendor`,
/// `scan`/`get --mode vendored`) builds from the same flags —
/// `--vendor-source` / `--vendor-url` / `--patch-server-url` /
/// `--offline` — so they commit byte-identical artifacts and lock
/// integrity for the same patch. `client` is the run-level API client
/// (moved in; the service reuses it for the package-reference request)
/// and `use_public_proxy` its proxy-fallback state. `vendor_source` was
/// validated by clap, so the parse cannot fail; the `auto` default is
/// the defensive fallback. A pure assembler (no async, no network).
pub(crate) fn vendor_service_config(
&self,
client: Option<ApiClient>,
use_public_proxy: bool,
) -> VendorServiceConfig {
VendorServiceConfig {
source: VendorSource::parse(&self.vendor_source).unwrap_or_default(),
client,
use_public_proxy,
vendor_url: self.vendor_url.clone(),
patch_server_url: self.patch_server_url.clone(),
offline: self.offline,
}
}
}

/// The `.socket/`-role directory for `manifest_path`: its parent, falling
/// back to `cwd` for a bare relative file name — never `"."`, which is
/// wrong under a non-default `--cwd`. [`GlobalArgs::resolved_manifest_path`]
/// always joins a relative path onto `cwd`, so the fallback is reachable
/// only for callers handed an unresolved path.
pub(crate) fn socket_dir_of(manifest_path: &Path, cwd: &Path) -> PathBuf {
manifest_path
.parent()
.filter(|p| !p.as_os_str().is_empty())
.map(Path::to_path_buf)
.unwrap_or_else(|| cwd.to_path_buf())
}

/// Apply CLI-flag toggles for env-driven knobs by mirroring them into env
Expand Down Expand Up @@ -530,9 +622,10 @@ mod tests {
}

/// Clear the extra env the core telemetry gate reads beyond the
/// `SOCKET_*` set (`is_telemetry_disabled` also consults `VITEST` and the
/// legacy `SOCKET_PATCH_TELEMETRY_DISABLED` name), so the airgap tests
/// below can't pass or fail vacuously. Restores afterwards.
/// `SOCKET_*` set (`is_telemetry_disabled` also consults `VITEST` — the
/// kill-switch socket-cli's vitest suite relies on — and the legacy
/// `SOCKET_PATCH_TELEMETRY_DISABLED` name), so the airgap tests below
/// can't pass or fail vacuously. Restores afterwards.
fn with_clean_telemetry_env(f: impl FnOnce()) {
with_env_cleared(&["VITEST", "SOCKET_PATCH_TELEMETRY_DISABLED"], f);
}
Expand Down Expand Up @@ -737,6 +830,76 @@ mod tests {
});
}

// ---- vendor_service_config ------------------------------------------
// Moved from scan's vendored flow: the config every vendor entry point
// builds must be the same assembler, so `scan --mode vendored` and a
// plain `vendor` commit byte-identical artifacts for the same patch.

fn common_with_source(source: &str) -> GlobalArgs {
GlobalArgs {
vendor_source: source.to_string(),
..Default::default()
}
}

/// Regression: scan's vendored flow must build its service config FROM
/// `--vendor-source`, not hardcode build-only (the pre-fix `service =
/// None`). Under the default (`auto`), the config must permit the
/// vendoring service exactly as the `vendor` command's default does —
/// otherwise `scan --mode vendored` silently builds locally while a
/// plain `vendor` service-downloads, and the two commit different bytes /
/// lock integrity for the same patch (lock churn / merge conflicts).
#[test]
fn vendor_service_config_default_source_permits_service() {
let cfg = common_with_source("auto").vendor_service_config(None, false);
assert_eq!(cfg.source, VendorSource::Auto);
assert!(
cfg.source.may_use_service(),
"the default must be able to use the service (matching `vendor`)"
);
assert!(!cfg.source.requires_service());
assert!(cfg.client.is_none());
assert!(!cfg.use_public_proxy);
}

/// `--vendor-source service` reaches the fail-closed service path and
/// `--vendor-source build` never contacts the service.
#[test]
fn vendor_service_config_honors_service_and_build_sources() {
let cfg = common_with_source("service").vendor_service_config(None, true);
assert_eq!(cfg.source, VendorSource::Service);
assert!(cfg.source.requires_service());
assert!(
cfg.use_public_proxy,
"the proxy-fallback state threads through"
);

let cfg = common_with_source("build").vendor_service_config(None, false);
assert_eq!(cfg.source, VendorSource::Build);
assert!(!cfg.source.may_use_service());
}

/// The service overrides (`--vendor-url` / `--patch-server-url` /
/// `--offline`) thread through unchanged, so every entry point targets
/// the same hosts.
#[test]
fn vendor_service_config_threads_overrides_through() {
let common = GlobalArgs {
vendor_source: "service".to_string(),
vendor_url: Some("https://vendor.example".to_string()),
patch_server_url: Some("https://patch.example".to_string()),
offline: true,
..Default::default()
};
let cfg = common.vendor_service_config(None, false);
assert_eq!(cfg.vendor_url.as_deref(), Some("https://vendor.example"));
assert_eq!(
cfg.patch_server_url.as_deref(),
Some("https://patch.example")
);
assert!(cfg.offline);
}

/// The new URL knobs flow through to the parsed args from CLI and env.
#[test]
#[serial_test::serial]
Expand Down Expand Up @@ -941,6 +1104,37 @@ mod tests {
assert!(o.org_slug.is_none());
}

/// Telemetry attribution runs the client's credential chain over the
/// same overrides: explicit values — the flag, or the env var clap folds
/// into the same field — are used verbatim, and empty means "unset"
/// (`Some("")` would build a malformed `/v0/orgs//telemetry` URL and an
/// empty `Bearer ` header). The ambient layers below the flags are
/// pinned in core (`resolve_ambient_credentials_*`) and end-to-end by
/// `tests/cli_config_fallback.rs::list_telemetry_follows_socket_cli_login`.
#[test]
fn telemetry_credentials_prefer_explicit_values_and_treat_empty_as_unset() {
let explicit = GlobalArgs {
api_token: Some("sktsec_flag_api".to_string()),
org: Some("flag-org".to_string()),
..GlobalArgs::default()
};
assert_eq!(
explicit.telemetry_credentials(),
(
Some("sktsec_flag_api".to_string()),
Some("flag-org".to_string())
)
);
let empty = GlobalArgs {
api_token: Some(String::new()),
org: Some(String::new()),
..GlobalArgs::default()
};
let (api_token, org_slug) = empty.telemetry_credentials();
assert_ne!(api_token.as_deref(), Some(""));
assert_ne!(org_slug.as_deref(), Some(""));
}

/// Empty strings for url/token/org are filtered out, not forwarded as
/// `Some("")` — otherwise an empty CLI value would mask env-var fallback.
#[test]
Expand Down Expand Up @@ -1003,6 +1197,68 @@ mod tests {
);
}

/// The default layout: `<cwd>/.socket/manifest.json` → the project
/// root is `cwd` and the socket dir is `<cwd>/.socket`.
#[test]
fn project_root_and_socket_dir_for_default_layout() {
let args = GlobalArgs {
cwd: PathBuf::from("/work/project"),
..GlobalArgs::default()
};
assert_eq!(args.project_root(), PathBuf::from("/work/project"));
assert_eq!(
args.socket_dir(),
PathBuf::from("/work/project").join(".socket")
);
}

/// `--manifest-path` into ANOTHER project's `.socket/`: every store
/// (manifest, vendor ledger, redirect ledger, lock) resolves against
/// that project — its `.socket` parent's parent — never the cwd.
#[test]
fn project_root_steps_out_of_a_foreign_socket_dir() {
let args = GlobalArgs {
cwd: PathBuf::from("/work/project"),
manifest_path: "../other/.socket/manifest.json".to_string(),
..GlobalArgs::default()
};
let other = PathBuf::from("/work/project").join("../other");
assert_eq!(args.project_root(), other);
assert_eq!(args.socket_dir(), other.join(".socket"));
}

/// A bare manifest file outside any `.socket/` layout: the file's own
/// directory plays both roles.
#[test]
fn project_root_of_a_bare_manifest_file_is_its_directory() {
let args = GlobalArgs {
cwd: PathBuf::from("/work/project"),
manifest_path: "custom/mp.json".to_string(),
..GlobalArgs::default()
};
let custom = PathBuf::from("/work/project").join("custom");
assert_eq!(args.project_root(), custom);
assert_eq!(args.socket_dir(), custom);
}

/// `socket_dir_of` on a raw relative file name falls back to `cwd`,
/// never to `"."` (wrong under a non-default `--cwd`); a resolved path
/// yields its parent.
#[test]
fn socket_dir_of_bare_relative_name_falls_back_to_cwd() {
assert_eq!(
socket_dir_of(Path::new("manifest.json"), Path::new("/work/project")),
PathBuf::from("/work/project"),
);
let resolved = PathBuf::from("/work/project")
.join(".socket")
.join("manifest.json");
assert_eq!(
socket_dir_of(&resolved, Path::new("/elsewhere")),
PathBuf::from("/work/project").join(".socket"),
);
}

/// `parse_supported_ecosystem` accepts every supported ecosystem name
/// and returns it verbatim.
#[test]
Expand Down
Loading
Loading