Skip to content

Commit 6cb84b8

Browse files
committed
test(cli): foundation for CLI-contract unit-test campaign
Sets up the foundation for a comprehensive unit-test campaign on the socket-patch CLI. No behavior change to the binary. Two changes: 1. Expose the clap parser as a library so integration tests can verify the public CLI contract without spawning the binary: - new crates/socket-patch-cli/src/lib.rs hosting Cli, Commands, looks_like_uuid, and parse_with_uuid_fallback (extracted from main.rs) - Cargo.toml gains [lib] entry alongside the existing [[bin]] - main.rs is now a thin wrapper that delegates to the lib 2. De-duplicate the manifest-path resolution block that was copy-pasted into 5 commands (apply, rollback, list, remove, repair). New helper socket_patch_core::manifest::operations::resolve_manifest_path handles the absolute-vs-relative join in one place, with 3 unit tests. 3. New CLI_CONTRACT.md next to the crate documenting every subcommand, flag, default, alias, JSON shape, and exit code as semver-significant surface. Adds a comment block above pub enum Commands pointing to it so anyone editing the parser sees the contract reminder. Verified: cargo build/clippy/test --workspace --all-features all clean (415 unit tests pass, including 3 new resolve_manifest_path tests). Foundation for follow-up PRs that add the per-command parser snapshot tests and helper unit tests. Assisted-by: Claude Code:claude-opus-4-7
1 parent 6288a37 commit 6cb84b8

10 files changed

Lines changed: 421 additions & 107 deletions

File tree

Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
1+
# socket-patch CLI contract
2+
3+
This document defines the **public surface** of the `socket-patch` binary. Anything listed here is part of the user-visible contract: third-party scripts, CI pipelines, and the npm/pypi/cargo wrappers depend on it. Changes are governed by the semver policy at the bottom of this file.
4+
5+
> **Why this exists.** Until late 2026 the CLI crate had zero unit tests under `src/` — only network-dependent `tests/e2e_*.rs` suites that run with `--ignored`. A flag rename, a default-value change, or a JSON key rename could land green and break every shipped wrapper silently. The contract below is now backed by the unit tests under `crates/socket-patch-cli/src/**` (`#[cfg(test)] mod tests`) and the parser tests under `crates/socket-patch-cli/tests/cli_parse_*.rs`. Changes that violate the contract must update those tests in lock-step with a major version bump.
6+
7+
## Subcommands
8+
9+
| Name | Visible alias(es) | Notes |
10+
|---|---|---|
11+
| `apply` || Apply patches from the local manifest |
12+
| `rollback` || Restore original files; takes optional positional `identifier` |
13+
| `get` | `download` | Fetch + apply patch; requires positional `identifier` |
14+
| `scan` || Crawl installed packages for available patches |
15+
| `list` || Print patches in the local manifest |
16+
| `remove` || Remove patch from manifest (rolls back first); requires positional `identifier` |
17+
| `setup` || Configure package.json postinstall scripts |
18+
| `repair` | `gc` | Download missing blobs + clean up unused ones |
19+
20+
**Bare-UUID fallback.** `socket-patch <UUID>` is rewritten to `socket-patch get <UUID>`. The UUID shape checked is the standard 8-4-4-4-12 hex pattern (case-insensitive). See [`src/lib.rs::looks_like_uuid`](src/lib.rs).
21+
22+
## Flags — long and short forms
23+
24+
Every flag below is part of the contract. The default values are pinned by parser tests.
25+
26+
### `apply`
27+
28+
| Long | Short | Default | Type |
29+
|---|---|---|---|
30+
| `--cwd` || `.` | path |
31+
| `--dry-run` | `-d` | `false` | bool |
32+
| `--silent` | `-s` | `false` | bool |
33+
| `--manifest-path` | `-m` | `.socket/manifest.json` | string |
34+
| `--offline` || `false` | bool |
35+
| `--global` | `-g` | `false` | bool |
36+
| `--global-prefix` || (none) | path |
37+
| `--ecosystems` || (none) | CSV → `Vec<String>` |
38+
| `--force` | `-f` | `false` | bool |
39+
| `--json` || `false` | bool |
40+
| `--verbose` | `-v` | `false` | bool |
41+
| `--download-mode` || **`diff`** | string |
42+
43+
### `rollback`
44+
45+
Same as `apply` plus: `--one-off` (bool), `--org` (string), `--api-url` (string), `--api-token` (string). Positional `identifier` is **optional** (omit to rollback everything).
46+
47+
### `get`
48+
49+
Required positional `identifier`. Flags:
50+
51+
| Long | Short | Alias | Default | Type |
52+
|---|---|---|---|---|
53+
| `--org` ||| (none) | string |
54+
| `--cwd` ||| `.` | path |
55+
| `--id` ||| `false` | bool |
56+
| `--cve` ||| `false` | bool |
57+
| `--ghsa` ||| `false` | bool |
58+
| `--package` | `-p` || `false` | bool |
59+
| `--yes` | `-y` || `false` | bool |
60+
| `--api-url` ||| (none) | string |
61+
| `--api-token` ||| (none) | string |
62+
| `--save-only` || **`--no-apply`** | `false` | bool |
63+
| `--global` | `-g` || `false` | bool |
64+
| `--global-prefix` ||| (none) | path |
65+
| `--one-off` ||| `false` | bool |
66+
| `--json` ||| `false` | bool |
67+
| `--download-mode` ||| **`diff`** | string |
68+
69+
The hidden alias `--no-apply` on `--save-only` is **part of the contract** — it does not appear in `--help` but is widely used in existing scripts.
70+
71+
### `scan`
72+
73+
| Long | Short | Default | Type |
74+
|---|---|---|---|
75+
| `--cwd` || `.` | path |
76+
| `--org` || (none) | string |
77+
| `--json` || `false` | bool |
78+
| `--yes` | `-y` | `false` | bool |
79+
| `--global` | `-g` | `false` | bool |
80+
| `--global-prefix` || (none) | path |
81+
| `--batch-size` || **`100`** | usize |
82+
| `--api-url` || (none) | string |
83+
| `--api-token` || (none) | string |
84+
| `--ecosystems` || (none) | CSV → `Vec<String>` |
85+
| `--download-mode` || **`diff`** | string |
86+
87+
### `list`
88+
89+
| Long | Short | Default | Type |
90+
|---|---|---|---|
91+
| `--cwd` || `.` | path |
92+
| `--manifest-path` | `-m` | `.socket/manifest.json` | string |
93+
| `--json` || `false` | bool |
94+
95+
### `remove`
96+
97+
Required positional `identifier`. Flags:
98+
99+
| Long | Short | Default | Type |
100+
|---|---|---|---|
101+
| `--cwd` || `.` | path |
102+
| `--manifest-path` | `-m` | `.socket/manifest.json` | string |
103+
| `--skip-rollback` || `false` | bool |
104+
| `--yes` | `-y` | `false` | bool |
105+
| `--global` | `-g` | `false` | bool |
106+
| `--global-prefix` || (none) | path |
107+
| `--json` || `false` | bool |
108+
109+
### `setup`
110+
111+
| Long | Short | Default | Type |
112+
|---|---|---|---|
113+
| `--cwd` || `.` | path |
114+
| `--dry-run` | `-d` | `false` | bool |
115+
| `--yes` | `-y` | `false` | bool |
116+
| `--json` || `false` | bool |
117+
118+
### `repair`
119+
120+
| Long | Short | Default | Type |
121+
|---|---|---|---|
122+
| `--cwd` || `.` | path |
123+
| `--manifest-path` | `-m` | `.socket/manifest.json` | string |
124+
| `--dry-run` | `-d` | `false` | bool |
125+
| `--offline` || `false` | bool |
126+
| `--download-only` || `false` | bool |
127+
| `--json` || `false` | bool |
128+
| `--download-mode` || **`file`** | string |
129+
130+
**Note:** `repair`'s `--download-mode` default differs from every other command (`file` vs `diff`). This is intentional — repair restores legacy per-file blobs needed to apply any patch.
131+
132+
## CSV value parsing
133+
134+
`--ecosystems` on `apply`, `rollback`, and `scan` uses clap's `value_delimiter = ','`. Input `--ecosystems npm,pypi,cargo` becomes `vec!["npm", "pypi", "cargo"]`. Switching to space-separated or dropping the delimiter is a **breaking** change.
135+
136+
## JSON output shapes
137+
138+
When `--json` is set, commands print a single JSON object to stdout. The schemas below are stable.
139+
140+
### Missing-manifest error (`apply`/`list`/`remove`/`repair`/`rollback`)
141+
142+
```json
143+
{
144+
"status": "error",
145+
"error": "Manifest not found",
146+
"path": "<absolute path that was looked up>"
147+
}
148+
```
149+
150+
### Invalid-manifest error
151+
152+
```json
153+
{ "status": "error", "error": "Invalid manifest" }
154+
```
155+
156+
### Generic error
157+
158+
```json
159+
{ "status": "error", "error": "<message>" }
160+
```
161+
162+
### `list` success — empty manifest
163+
164+
```json
165+
{ "status": "success", "patches": [] }
166+
```
167+
168+
### `list` success — populated
169+
170+
```json
171+
{
172+
"status": "success",
173+
"patches": [
174+
{
175+
"purl": "pkg:npm/foo@1.2.3",
176+
"uuid": "",
177+
"exportedAt": "",
178+
"tier": "free|paid",
179+
"license": "",
180+
"description": "",
181+
"files": [""],
182+
"vulnerabilities": [
183+
{ "id": "", "cves": [""], "summary": "", "severity": "", "description": "" }
184+
]
185+
}
186+
]
187+
}
188+
```
189+
190+
### `setup` — no package.json files found
191+
192+
```json
193+
{
194+
"status": "no_files",
195+
"updated": 0,
196+
"alreadyConfigured": 0,
197+
"errors": 0,
198+
"files": []
199+
}
200+
```
201+
202+
### `get` — multiple-patch selection required (JSON mode)
203+
204+
```json
205+
{
206+
"status": "selection_required",
207+
"error": "Multiple patches available for <purl>. Specify --id <UUID> to select one.",
208+
"purl": "<purl>",
209+
"options": [
210+
{ "uuid": "", "tier": "", "published_at": "", "description": "", "vulnerabilities": [ ] }
211+
]
212+
}
213+
```
214+
215+
## Exit codes
216+
217+
| Code | Meaning |
218+
|---|---|
219+
| `0` | Success |
220+
| `1` | Error (missing/invalid manifest, fetch failed, apply failed, selection cancelled in non-JSON mode, etc.) |
221+
222+
`list` returns **`0`** for an empty manifest and **`1`** for a missing manifest — these are distinct and load-bearing.
223+
224+
## Semver policy
225+
226+
Versioning lives in **`Cargo.toml`** at the workspace root (`version = "..."`) and is propagated to npm, pypi, and cargo wrappers by **`scripts/version-sync.sh <new-version>`**.
227+
228+
| Change | Bump |
229+
|---|---|
230+
| Rename or remove a subcommand | **MAJOR** |
231+
| Rename or remove a visible alias (`download`, `gc`) | **MAJOR** |
232+
| Rename or remove a hidden alias (`--no-apply`) | **MAJOR** |
233+
| Rename, remove, or change short form of a flag (`-d`, `-m`, etc.) | **MAJOR** |
234+
| Change a default value (`--download-mode`, `--batch-size`, `--manifest-path`, …) | **MAJOR** |
235+
| Change an exit code's meaning or add a new non-zero code with different semantics | **MAJOR** |
236+
| Rename a JSON output key or change a `status` string | **MAJOR** |
237+
| Remove a JSON output key | **MAJOR** |
238+
| Drop the bare-UUID fallback | **MAJOR** |
239+
| Add a *required* new flag | **MAJOR** |
240+
| Add a new subcommand | **MINOR** |
241+
| Add a new optional flag | **MINOR** |
242+
| Add a new optional JSON output key (additive) | **MINOR** |
243+
| Add a new visible alias to an existing subcommand | **MINOR** |
244+
| Fix a bug without changing any of the above | **PATCH** |
245+
246+
After bumping `Cargo.toml`, run:
247+
248+
```bash
249+
scripts/version-sync.sh <new-version>
250+
```
251+
252+
This syncs the workspace package version into:
253+
254+
- `npm/socket-patch/package.json` (and its `optionalDependencies`)
255+
- every per-platform `npm/socket-patch-*/package.json`
256+
- `pypi/socket-patch/pyproject.toml`
257+
258+
## How the contract is enforced
259+
260+
Every item in this document is locked in by at least one of:
261+
262+
- **clap parser snapshots** in `crates/socket-patch-cli/tests/cli_parse_*.rs` — assert flag names, short forms, defaults, aliases, and CSV delimiters by calling `socket_patch_cli::Cli::try_parse_from(...)`.
263+
- **Helper unit tests** in `crates/socket-patch-cli/src/**` (`#[cfg(test)] mod tests` blocks) — cover `looks_like_uuid`, `parse_with_uuid_fallback`, `detect_identifier_type`, `select_patches`, `find_patches_to_rollback`, `partition_purls`, `verify_status_str`, `format_severity`, `color`, and the JSON serializers.
264+
- **Async `run()` integration tests** in `tests/cli_parse_list.rs`, `tests/cli_parse_remove.rs`, `tests/cli_parse_setup.rs` — exercise the no-network error paths and assert JSON shape via `serde_json::from_str::<Value>` + per-key assertions.
265+
266+
If you add a new flag/subcommand/JSON key, add a test here that locks the new surface in the same PR.

crates/socket-patch-cli/Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ license.workspace = true
77
repository.workspace = true
88
readme = "README.md"
99

10+
[lib]
11+
name = "socket_patch_cli"
12+
path = "src/lib.rs"
13+
1014
[[bin]]
1115
name = "socket-patch"
1216
path = "src/main.rs"

crates/socket-patch-cli/src/commands/apply.rs

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ use socket_patch_core::api::blob_fetcher::{
66
use socket_patch_core::api::client::get_api_client_from_env;
77
use socket_patch_core::constants::DEFAULT_PATCH_MANIFEST_PATH;
88
use socket_patch_core::crawlers::{CrawlerOptions, Ecosystem};
9-
use socket_patch_core::manifest::operations::read_manifest;
9+
use socket_patch_core::manifest::operations::{read_manifest, resolve_manifest_path};
1010
use socket_patch_core::patch::apply::{
1111
apply_package_patch, verify_file_patch, ApplyResult, PatchSources, VerifyStatus,
1212
};
@@ -113,11 +113,7 @@ pub async fn run(args: ApplyArgs) -> i32 {
113113
let api_token = telemetry_client.api_token().cloned();
114114
let org_slug = telemetry_client.org_slug().cloned();
115115

116-
let manifest_path = if Path::new(&args.manifest_path).is_absolute() {
117-
PathBuf::from(&args.manifest_path)
118-
} else {
119-
args.cwd.join(&args.manifest_path)
120-
};
116+
let manifest_path = resolve_manifest_path(&args.cwd, &args.manifest_path);
121117

122118
// Check if manifest exists - exit successfully if no .socket folder is set up
123119
if tokio::fs::metadata(&manifest_path).await.is_err() {

crates/socket-patch-cli/src/commands/list.rs

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
use clap::Args;
22
use socket_patch_core::constants::DEFAULT_PATCH_MANIFEST_PATH;
3-
use socket_patch_core::manifest::operations::read_manifest;
4-
use std::path::{Path, PathBuf};
3+
use socket_patch_core::manifest::operations::{read_manifest, resolve_manifest_path};
4+
use std::path::PathBuf;
55

66
#[derive(Args)]
77
pub struct ListArgs {
@@ -19,11 +19,7 @@ pub struct ListArgs {
1919
}
2020

2121
pub async fn run(args: ListArgs) -> i32 {
22-
let manifest_path = if Path::new(&args.manifest_path).is_absolute() {
23-
PathBuf::from(&args.manifest_path)
24-
} else {
25-
args.cwd.join(&args.manifest_path)
26-
};
22+
let manifest_path = resolve_manifest_path(&args.cwd, &args.manifest_path);
2723

2824
// Check if manifest exists
2925
if tokio::fs::metadata(&manifest_path).await.is_err() {

crates/socket-patch-cli/src/commands/remove.rs

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
use clap::Args;
22
use socket_patch_core::constants::DEFAULT_PATCH_MANIFEST_PATH;
3-
use socket_patch_core::manifest::operations::{read_manifest, write_manifest};
3+
use socket_patch_core::manifest::operations::{
4+
read_manifest, resolve_manifest_path, write_manifest,
5+
};
46
use socket_patch_core::manifest::schema::PatchManifest;
57
use socket_patch_core::utils::cleanup_blobs::{cleanup_unused_blobs, format_cleanup_result};
68
use socket_patch_core::utils::telemetry::{track_patch_removed, track_patch_remove_failed};
@@ -49,11 +51,7 @@ pub async fn run(args: RemoveArgs) -> i32 {
4951
let api_token = telemetry_client.api_token().cloned();
5052
let org_slug = telemetry_client.org_slug().cloned();
5153

52-
let manifest_path = if Path::new(&args.manifest_path).is_absolute() {
53-
PathBuf::from(&args.manifest_path)
54-
} else {
55-
args.cwd.join(&args.manifest_path)
56-
};
54+
let manifest_path = resolve_manifest_path(&args.cwd, &args.manifest_path);
5755

5856
if tokio::fs::metadata(&manifest_path).await.is_err() {
5957
if args.json {

crates/socket-patch-cli/src/commands/repair.rs

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ use socket_patch_core::api::blob_fetcher::{
55
};
66
use socket_patch_core::api::client::get_api_client_from_env;
77
use socket_patch_core::constants::DEFAULT_PATCH_MANIFEST_PATH;
8-
use socket_patch_core::manifest::operations::read_manifest;
8+
use socket_patch_core::manifest::operations::{read_manifest, resolve_manifest_path};
99
use socket_patch_core::patch::apply::PatchSources;
1010
use socket_patch_core::utils::cleanup_blobs::{
1111
cleanup_unused_archives, cleanup_unused_blobs, format_cleanup_result,
@@ -46,11 +46,7 @@ pub struct RepairArgs {
4646
}
4747

4848
pub async fn run(args: RepairArgs) -> i32 {
49-
let manifest_path = if Path::new(&args.manifest_path).is_absolute() {
50-
PathBuf::from(&args.manifest_path)
51-
} else {
52-
args.cwd.join(&args.manifest_path)
53-
};
49+
let manifest_path = resolve_manifest_path(&args.cwd, &args.manifest_path);
5450

5551
if tokio::fs::metadata(&manifest_path).await.is_err() {
5652
if args.json {

crates/socket-patch-cli/src/commands/rollback.rs

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ use socket_patch_core::api::blob_fetcher::{
55
use socket_patch_core::api::client::get_api_client_from_env;
66
use socket_patch_core::constants::DEFAULT_PATCH_MANIFEST_PATH;
77
use socket_patch_core::crawlers::CrawlerOptions;
8-
use socket_patch_core::manifest::operations::read_manifest;
8+
use socket_patch_core::manifest::operations::{read_manifest, resolve_manifest_path};
99
use socket_patch_core::manifest::schema::{PatchManifest, PatchRecord};
1010
use socket_patch_core::patch::rollback::{rollback_package_patch, RollbackResult, VerifyRollbackStatus};
1111
use socket_patch_core::utils::telemetry::{track_patch_rolled_back, track_patch_rollback_failed};
@@ -212,11 +212,7 @@ pub async fn run(args: RollbackArgs) -> i32 {
212212
return 1;
213213
}
214214

215-
let manifest_path = if Path::new(&args.manifest_path).is_absolute() {
216-
PathBuf::from(&args.manifest_path)
217-
} else {
218-
args.cwd.join(&args.manifest_path)
219-
};
215+
let manifest_path = resolve_manifest_path(&args.cwd, &args.manifest_path);
220216

221217
if tokio::fs::metadata(&manifest_path).await.is_err() {
222218
if args.json {

0 commit comments

Comments
 (0)