Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Prev Previous commit
Next Next commit
update cli invariants
  • Loading branch information
mikolalysenko committed Jun 5, 2026
commit 2fef6423f0553357be8e8bb97647ff17e7c3bfb3
153 changes: 151 additions & 2 deletions crates/socket-patch-cli/CLI_CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ Beyond the globals above, each subcommand defines a small set of local arguments
| `rollback` | optional positional `identifier`; `--one-off` | `SOCKET_ONE_OFF` | Rollback target |
| `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 |
| `repair` | `--download-only` | `SOCKET_DOWNLOAD_ONLY` | Repair-specific cleanup mode (mutually exclusive with `--offline`) |
| `setup` | (none beyond globals) | — | — |
| `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) |

`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.

Expand All @@ -89,6 +89,155 @@ Contract details:

`repair` keeps its `gc` visible alias.

## Setup command contract

`setup` wires a repository for **automatic patching**: after the ecosystem's own install/build step
runs, locally-installed dependencies are re-patched to match the Socket manifest (`.socket/manifest.json`)
with no further human action. It does this by installing an ecosystem-native hook (see the support
matrix below). `setup --check` verifies that state; `setup --remove` reverts it.

The properties below are the public contract. Each is backed by a test under
`crates/socket-patch-cli/tests/setup_*.rs`; properties not yet fully implemented are called out
explicitly and guarded by a deliberately-failing (RED) test that encodes the intended behavior — these
are the executable spec for follow-up work, **not** regressions. Changing any property below is governed
by the [semver policy](#semver-policy) (scoping `setup` by `--ecosystems` and strengthening `--check`,
in particular, are behavior changes that gate a version bump when implemented).

1. **Idempotent.** Re-running `setup` on an already-configured repo changes nothing: status
`already_configured`, `updated: 0`, every manifest byte-identical. *(Implemented.)*

2. **Ecosystem-scoped.** `setup`, `setup --check`, and `setup --remove` honor the global
`--ecosystems` filter and act on only the named ecosystems; with no filter they act on every
detected ecosystem. *(Intended; **not yet implemented** — `setup` currently ignores `--ecosystems`
and always processes npm + python + cargo. RED-guarded.)*

3. **Consistency after install.** Once an ecosystem is set up, its locally-installed dependencies are
re-patched to match the manifest after **any** of: a dependency added, updated, or removed; **or** a
new patch added to the manifest. The re-patch is carried by the ecosystem's install/build hook (npm
`postinstall`/`dependencies`, the Python `.pth` startup hook, the cargo guard build script) which
runs `socket-patch apply` after the ecosystem's installer finishes, so patch state always reconverges
with the manifest. *(Implemented for npm/pypi/cargo via the support matrix.)*

4. **`check` proves a correctly-patched state.** `setup --check` reports `configured` only when the
in-scope ecosystems are *actually in a correctly patched state* — install hooks present **and**
on-disk patch consistency verified (the `apply --check` invariant: every manifest file's hash matches
`afterHash`). *(Partially implemented; **hook-presence only today** — `check` does not yet verify
on-disk patch consistency. RED-guarded.)*

5. **In-repo and committable.** `setup` writes only inside the working tree: `package.json`,
`pyproject.toml`/`requirements.txt`, member `Cargo.toml`s, and `.cargo/config.toml`. Every artifact
is git-committable. It never writes outside `--cwd` — no `$HOME`, no global `site-packages` (the
Python `.pth` wheel is installed later by the user's package manager, not by `setup`). *(Implemented.)*

6. **Clone-portable.** Because all setup state is committed files, a fresh checkout on another host —
CI, a deploy, a teammate's machine — inherits the setup state unchanged; `setup --check` passes on
the clone with no re-run required. *(Implemented; a consequence of properties 5 + 1.)*

7. **Reflected in VEX.** A patch contributes a `not_affected` statement to the repo's OpenVEX document
only for ecosystems that are **actually set up** — or explicitly declared **manual** (below). Patches
for an ecosystem that is neither set up nor declared manual produce no VEX statement. *(Intended;
**not yet implemented** — VEX currently filters by `--ecosystems` and on-disk verification but has no
notion of setup state. RED-guarded.)*
- **Manual declaration.** Users who run `socket-patch apply` by hand (e.g. in a CI step) can declare
an ecosystem or individual hook as `manual`, so VEX still attests its patches even though the
auto-install hook is intentionally not wired. Intended home: a sub-property of
`.socket/manifest.json`. *(Follow-up work.)*

8. **Graceful, exact remove.** `setup --remove` (optionally per-ecosystem via `--ecosystems`) restores
the repo to its exact pre-setup state: manifests byte-for-byte, sibling scripts/dependencies
preserved, keys that became empty dropped. Afterward `setup --check` reports needs-configuration
again. *(Implemented for the manifest edits — npm `package.json`, Python deps, and member
`Cargo.toml`s all round-trip byte-for-byte. **Known residue:** a `.cargo/config.toml` (and its
`.cargo/` dir) that `setup` created is left behind empty rather than deleted on `--remove`;
RED-guarded.)*

9. **Nested workspaces, with exclude.** Setup applies to every subproject below the repo root: npm /
yarn / pnpm / bun workspace members and cargo workspace members are all discovered and configured
(pnpm is root-package-only by design, because workspace-member `postinstall` scripts fail under
pnpm's strict module isolation). Selected paths may be **excluded**, and the exclusion is **persisted
in `.socket/manifest.json`** so `check`, `apply`, and any clone all honor it. *(Workspace discovery
implemented; the `--exclude` flag + manifest exclude sub-property are **follow-up work** — pending
test marked `#[ignore]`.)*

### Per-ecosystem setup support

`setup` only installs an automatic-repatch hook for the three ecosystems with a native post-install /
build hook. The remaining ecosystems are **apply-only**: `socket-patch apply` patches them on demand,
but there is no hook for `setup` to install, so `setup` is a `no_files` no-op for them. These are
exactly the ecosystems for which property 7's **manual** declaration is intended (so their hand-applied
patches still show up in VEX).

| Ecosystem | Hook `setup` installs | Repatch trigger | Notes |
|---|---|---|---|
| npm / yarn / pnpm / bun | `scripts.postinstall` + `scripts.dependencies` | `npm/pnpm install` (+ `install <pkg>`) | pnpm: root package only |
| 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) |
| 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]` |
| gem · nuget · maven · golang · composer · deno | **none** (apply-only) | — | `setup` reports `no_files`; candidates for the **manual** declaration |

### JSON output shapes (`setup`, `setup --check`, `setup --remove`)

`setup` predates the v3.0 unified envelope and emits its own three shapes. They are stable as of v3.0;
consumers may rely on these keys. All three share a `files[*]` entry shape; `kind` is one of
`package_json`, `pth`, `cargo`, `cargo_env`.

**`setup`:**

```jsonc
{
"status": "success" | "already_configured" | "dry_run" | "partial_failure" | "error" | "no_files",
"updated": 0,
"alreadyConfigured": 0,
"errors": 0,
"packageManager": "npm" | "pnpm", // always emitted; defaults to "npm", only meaningful when npm files were found
"pythonPackageManager":"pip" | "uv" | "poetry" | "pdm" | "hatch", // present only when Python detected
"dryRun": true, // only on status=dry_run
"wouldUpdate": 0, // only on status=dry_run
"warnings": [ "..." ], // only when non-empty (e.g. lockfile refresh)
"files": [
{ "kind": "package_json", "path": "...", "status": "updated" | "already_configured" | "error",
"error": null | "..." }
]
}
```

**`setup --check`** (read-only; never writes — exit `0` only when all in-scope manifests are configured
and none errored):

```jsonc
{
"status": "configured" | "needs_configuration" | "error" | "no_files",
"configured": 0,
"needsConfiguration": 0,
"errors": 0,
"files": [
{ "kind": "...", "path": "...", "status": "configured" | "needs_configuration" | "error",
"error": null | "..." }
]
}
```

**`setup --remove`:**

```jsonc
{
"status": "success" | "not_configured" | "dry_run" | "partial_failure" | "error" | "no_files",
"removed": 0,
"notConfigured": 0,
"errors": 0,
"dryRun": true, // only on status=dry_run
"wouldRemove": 0, // only on status=dry_run
"warnings": [ "..." ], // only when non-empty
"files": [
{ "kind": "...", "path": "...", "status": "removed" | "not_configured" | "error",
"error": null | "..." }
]
}
```

**Exit codes** (all three): `0` when nothing errored and the operation was satisfiable (including
`no_files` and `not_configured`); `1` on any per-file error, partial failure, or — for `--check` — any
manifest that needs configuration. `setup --check --remove` is a clap usage error (exit `2`).

## Environment variables

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.
Expand Down Expand Up @@ -247,7 +396,7 @@ The remaining commands still emit their pre-v3.0 ad-hoc JSON shapes and will mig
- ⏳ `scan` — still emits the discovery + `apply.patches[*]` + `gc.*` shape documented in earlier drafts of this file.
- ⏳ `get` — still emits per-patch action arrays.
- ⏳ `rollback` — still emits per-package result records.
- ⏳ `setup` — still emits `{ status, updated, alreadyConfigured, errors, files }`.
- ⏳ `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).

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

Expand Down
26 changes: 26 additions & 0 deletions crates/socket-patch-cli/tests/cli_parse_setup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,32 @@ fn remove_long_form() {
assert!(!args.check);
}

#[test]
fn ecosystems_flag_parses_on_setup() {
// Setup command contract, property 2 ("ecosystem-scoped"): `setup` accepts
// the global `--ecosystems` filter (long form + the `-e` short form, CSV
// split). This pins the *parse* surface only; whether `setup` actually
// restricts its work to the named ecosystems at runtime is a separate
// (currently unimplemented) guarantee, RED-guarded in setup_contract_gaps.rs.
let long = parse_setup(&["--ecosystems", "npm,cargo"]);
assert_eq!(
long.common.ecosystems.as_deref(),
Some(&["npm".to_string(), "cargo".to_string()][..]),
"setup must parse the CSV --ecosystems filter (long form)"
);
let short = parse_setup(&["-e", "pypi"]);
assert_eq!(
short.common.ecosystems.as_deref(),
Some(&["pypi".to_string()][..]),
"setup must accept the -e short form"
);
// Default: no filter ⇒ act on every detected ecosystem.
assert!(
parse_setup(&[]).common.ecosystems.is_none(),
"no --ecosystems ⇒ None"
);
}

#[test]
fn check_and_remove_conflict() {
let result = Cli::try_parse_from(["socket-patch", "setup", "--check", "--remove"]);
Expand Down
Loading
Loading