Skip to content

Commit 2fef642

Browse files
committed
update cli invariants
1 parent fc47a21 commit 2fef642

6 files changed

Lines changed: 975 additions & 2 deletions

File tree

crates/socket-patch-cli/CLI_CONTRACT.md

Lines changed: 151 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ Beyond the globals above, each subcommand defines a small set of local arguments
6363
| `rollback` | optional positional `identifier`; `--one-off` | `SOCKET_ONE_OFF` | Rollback target |
6464
| `vex` | `--output` / `-O`, `--product`, `--no-verify`, `--doc-id`, `--compact` | `SOCKET_VEX_OUTPUT`, `SOCKET_VEX_PRODUCT`, `SOCKET_VEX_NO_VERIFY`, `SOCKET_VEX_DOC_ID`, `SOCKET_VEX_COMPACT` | OpenVEX 0.2.0 document generation; see "vex output channels" below |
6565
| `repair` | `--download-only` | `SOCKET_DOWNLOAD_ONLY` | Repair-specific cleanup mode (mutually exclusive with `--offline`) |
66-
| `setup` | (none beyond globals) | | |
66+
| `setup` | `--check`, `--remove` (mutually exclusive); honors global `--ecosystems` | `SOCKET_ECOSYSTEMS` | Wire / verify / revert the automatic-patching install hooks. See [Setup command contract](#setup-command-contract) |
6767

6868
`scan --apply` opts JSON callers into the full discover → select → apply pipeline. Without it, `scan --json` stays read-only (discovery + `updates` array only). No effect outside `--json` mode — the non-JSON path always prompts the user interactively.
6969

@@ -89,6 +89,155 @@ Contract details:
8989

9090
`repair` keeps its `gc` visible alias.
9191

92+
## Setup command contract
93+
94+
`setup` wires a repository for **automatic patching**: after the ecosystem's own install/build step
95+
runs, locally-installed dependencies are re-patched to match the Socket manifest (`.socket/manifest.json`)
96+
with no further human action. It does this by installing an ecosystem-native hook (see the support
97+
matrix below). `setup --check` verifies that state; `setup --remove` reverts it.
98+
99+
The properties below are the public contract. Each is backed by a test under
100+
`crates/socket-patch-cli/tests/setup_*.rs`; properties not yet fully implemented are called out
101+
explicitly and guarded by a deliberately-failing (RED) test that encodes the intended behavior — these
102+
are the executable spec for follow-up work, **not** regressions. Changing any property below is governed
103+
by the [semver policy](#semver-policy) (scoping `setup` by `--ecosystems` and strengthening `--check`,
104+
in particular, are behavior changes that gate a version bump when implemented).
105+
106+
1. **Idempotent.** Re-running `setup` on an already-configured repo changes nothing: status
107+
`already_configured`, `updated: 0`, every manifest byte-identical. *(Implemented.)*
108+
109+
2. **Ecosystem-scoped.** `setup`, `setup --check`, and `setup --remove` honor the global
110+
`--ecosystems` filter and act on only the named ecosystems; with no filter they act on every
111+
detected ecosystem. *(Intended; **not yet implemented**`setup` currently ignores `--ecosystems`
112+
and always processes npm + python + cargo. RED-guarded.)*
113+
114+
3. **Consistency after install.** Once an ecosystem is set up, its locally-installed dependencies are
115+
re-patched to match the manifest after **any** of: a dependency added, updated, or removed; **or** a
116+
new patch added to the manifest. The re-patch is carried by the ecosystem's install/build hook (npm
117+
`postinstall`/`dependencies`, the Python `.pth` startup hook, the cargo guard build script) which
118+
runs `socket-patch apply` after the ecosystem's installer finishes, so patch state always reconverges
119+
with the manifest. *(Implemented for npm/pypi/cargo via the support matrix.)*
120+
121+
4. **`check` proves a correctly-patched state.** `setup --check` reports `configured` only when the
122+
in-scope ecosystems are *actually in a correctly patched state* — install hooks present **and**
123+
on-disk patch consistency verified (the `apply --check` invariant: every manifest file's hash matches
124+
`afterHash`). *(Partially implemented; **hook-presence only today**`check` does not yet verify
125+
on-disk patch consistency. RED-guarded.)*
126+
127+
5. **In-repo and committable.** `setup` writes only inside the working tree: `package.json`,
128+
`pyproject.toml`/`requirements.txt`, member `Cargo.toml`s, and `.cargo/config.toml`. Every artifact
129+
is git-committable. It never writes outside `--cwd` — no `$HOME`, no global `site-packages` (the
130+
Python `.pth` wheel is installed later by the user's package manager, not by `setup`). *(Implemented.)*
131+
132+
6. **Clone-portable.** Because all setup state is committed files, a fresh checkout on another host —
133+
CI, a deploy, a teammate's machine — inherits the setup state unchanged; `setup --check` passes on
134+
the clone with no re-run required. *(Implemented; a consequence of properties 5 + 1.)*
135+
136+
7. **Reflected in VEX.** A patch contributes a `not_affected` statement to the repo's OpenVEX document
137+
only for ecosystems that are **actually set up** — or explicitly declared **manual** (below). Patches
138+
for an ecosystem that is neither set up nor declared manual produce no VEX statement. *(Intended;
139+
**not yet implemented** — VEX currently filters by `--ecosystems` and on-disk verification but has no
140+
notion of setup state. RED-guarded.)*
141+
- **Manual declaration.** Users who run `socket-patch apply` by hand (e.g. in a CI step) can declare
142+
an ecosystem or individual hook as `manual`, so VEX still attests its patches even though the
143+
auto-install hook is intentionally not wired. Intended home: a sub-property of
144+
`.socket/manifest.json`. *(Follow-up work.)*
145+
146+
8. **Graceful, exact remove.** `setup --remove` (optionally per-ecosystem via `--ecosystems`) restores
147+
the repo to its exact pre-setup state: manifests byte-for-byte, sibling scripts/dependencies
148+
preserved, keys that became empty dropped. Afterward `setup --check` reports needs-configuration
149+
again. *(Implemented for the manifest edits — npm `package.json`, Python deps, and member
150+
`Cargo.toml`s all round-trip byte-for-byte. **Known residue:** a `.cargo/config.toml` (and its
151+
`.cargo/` dir) that `setup` created is left behind empty rather than deleted on `--remove`;
152+
RED-guarded.)*
153+
154+
9. **Nested workspaces, with exclude.** Setup applies to every subproject below the repo root: npm /
155+
yarn / pnpm / bun workspace members and cargo workspace members are all discovered and configured
156+
(pnpm is root-package-only by design, because workspace-member `postinstall` scripts fail under
157+
pnpm's strict module isolation). Selected paths may be **excluded**, and the exclusion is **persisted
158+
in `.socket/manifest.json`** so `check`, `apply`, and any clone all honor it. *(Workspace discovery
159+
implemented; the `--exclude` flag + manifest exclude sub-property are **follow-up work** — pending
160+
test marked `#[ignore]`.)*
161+
162+
### Per-ecosystem setup support
163+
164+
`setup` only installs an automatic-repatch hook for the three ecosystems with a native post-install /
165+
build hook. The remaining ecosystems are **apply-only**: `socket-patch apply` patches them on demand,
166+
but there is no hook for `setup` to install, so `setup` is a `no_files` no-op for them. These are
167+
exactly the ecosystems for which property 7's **manual** declaration is intended (so their hand-applied
168+
patches still show up in VEX).
169+
170+
| Ecosystem | Hook `setup` installs | Repatch trigger | Notes |
171+
|---|---|---|---|
172+
| npm / yarn / pnpm / bun | `scripts.postinstall` + `scripts.dependencies` | `npm/pnpm install` (+ `install <pkg>`) | pnpm: root package only |
173+
| pypi | `socket-patch[hook]` dependency → `.pth` startup hook | Python interpreter startup after installed-set change | manifest = `pyproject.toml` (uv/poetry/pdm/hatch) or `requirements.txt` (pip) |
174+
| cargo | `socket-patch-guard` dependency + `[env] SOCKET_PATCH_ROOT` in `.cargo/config.toml` | every `cargo build` (fail-closed guard) | per-member dep + one workspace-root `[env]` |
175+
| gem · nuget · maven · golang · composer · deno | **none** (apply-only) || `setup` reports `no_files`; candidates for the **manual** declaration |
176+
177+
### JSON output shapes (`setup`, `setup --check`, `setup --remove`)
178+
179+
`setup` predates the v3.0 unified envelope and emits its own three shapes. They are stable as of v3.0;
180+
consumers may rely on these keys. All three share a `files[*]` entry shape; `kind` is one of
181+
`package_json`, `pth`, `cargo`, `cargo_env`.
182+
183+
**`setup`:**
184+
185+
```jsonc
186+
{
187+
"status": "success" | "already_configured" | "dry_run" | "partial_failure" | "error" | "no_files",
188+
"updated": 0,
189+
"alreadyConfigured": 0,
190+
"errors": 0,
191+
"packageManager": "npm" | "pnpm", // always emitted; defaults to "npm", only meaningful when npm files were found
192+
"pythonPackageManager":"pip" | "uv" | "poetry" | "pdm" | "hatch", // present only when Python detected
193+
"dryRun": true, // only on status=dry_run
194+
"wouldUpdate": 0, // only on status=dry_run
195+
"warnings": [ "..." ], // only when non-empty (e.g. lockfile refresh)
196+
"files": [
197+
{ "kind": "package_json", "path": "...", "status": "updated" | "already_configured" | "error",
198+
"error": null | "..." }
199+
]
200+
}
201+
```
202+
203+
**`setup --check`** (read-only; never writes — exit `0` only when all in-scope manifests are configured
204+
and none errored):
205+
206+
```jsonc
207+
{
208+
"status": "configured" | "needs_configuration" | "error" | "no_files",
209+
"configured": 0,
210+
"needsConfiguration": 0,
211+
"errors": 0,
212+
"files": [
213+
{ "kind": "...", "path": "...", "status": "configured" | "needs_configuration" | "error",
214+
"error": null | "..." }
215+
]
216+
}
217+
```
218+
219+
**`setup --remove`:**
220+
221+
```jsonc
222+
{
223+
"status": "success" | "not_configured" | "dry_run" | "partial_failure" | "error" | "no_files",
224+
"removed": 0,
225+
"notConfigured": 0,
226+
"errors": 0,
227+
"dryRun": true, // only on status=dry_run
228+
"wouldRemove": 0, // only on status=dry_run
229+
"warnings": [ "..." ], // only when non-empty
230+
"files": [
231+
{ "kind": "...", "path": "...", "status": "removed" | "not_configured" | "error",
232+
"error": null | "..." }
233+
]
234+
}
235+
```
236+
237+
**Exit codes** (all three): `0` when nothing errored and the operation was satisfiable (including
238+
`no_files` and `not_configured`); `1` on any per-file error, partial failure, or — for `--check` — any
239+
manifest that needs configuration. `setup --check --remove` is a clap usage error (exit `2`).
240+
92241
## Environment variables
93242

94243
All v3.0 env vars use the `SOCKET_*` prefix. Three legacy `SOCKET_PATCH_*` names are still honored at runtime for compatibility: on first read of any of the three the binary emits a one-shot deprecation warning to stderr (the warning fires unconditionally — even under `--silent` / `--json` — because it's a transition signal users need to see). The legacy names will be removed in the next major release.
@@ -247,7 +396,7 @@ The remaining commands still emit their pre-v3.0 ad-hoc JSON shapes and will mig
247396
-`scan` — still emits the discovery + `apply.patches[*]` + `gc.*` shape documented in earlier drafts of this file.
248397
-`get` — still emits per-patch action arrays.
249398
-`rollback` — still emits per-package result records.
250-
-`setup` — still emits `{ status, updated, alreadyConfigured, errors, files }`.
399+
-`setup` — still emits its own `{ status, updated, alreadyConfigured, errors, files }` shape (and the `--check` / `--remove` variants), now documented in full under [Setup command contract](#setup-command-contract).
251400

252401
### `patches[]` entry shape for `get` and `scan --apply`
253402

crates/socket-patch-cli/tests/cli_parse_setup.rs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,32 @@ fn remove_long_form() {
8888
assert!(!args.check);
8989
}
9090

91+
#[test]
92+
fn ecosystems_flag_parses_on_setup() {
93+
// Setup command contract, property 2 ("ecosystem-scoped"): `setup` accepts
94+
// the global `--ecosystems` filter (long form + the `-e` short form, CSV
95+
// split). This pins the *parse* surface only; whether `setup` actually
96+
// restricts its work to the named ecosystems at runtime is a separate
97+
// (currently unimplemented) guarantee, RED-guarded in setup_contract_gaps.rs.
98+
let long = parse_setup(&["--ecosystems", "npm,cargo"]);
99+
assert_eq!(
100+
long.common.ecosystems.as_deref(),
101+
Some(&["npm".to_string(), "cargo".to_string()][..]),
102+
"setup must parse the CSV --ecosystems filter (long form)"
103+
);
104+
let short = parse_setup(&["-e", "pypi"]);
105+
assert_eq!(
106+
short.common.ecosystems.as_deref(),
107+
Some(&["pypi".to_string()][..]),
108+
"setup must accept the -e short form"
109+
);
110+
// Default: no filter ⇒ act on every detected ecosystem.
111+
assert!(
112+
parse_setup(&[]).common.ecosystems.is_none(),
113+
"no --ecosystems ⇒ None"
114+
);
115+
}
116+
91117
#[test]
92118
fn check_and_remove_conflict() {
93119
let result = Cli::try_parse_from(["socket-patch", "setup", "--check", "--remove"]);

0 commit comments

Comments
 (0)