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
146 changes: 79 additions & 67 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,85 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
argv (`ps`, shell history), and a password field in a native window has
neither.

- **The project env vault** ([`pb env`](docs/env-vault.md)) — the variables a
*project* needs, held the way the key vault holds credentials: names and
provenance in `~/.config/patchbay/projects.json` (`0600`), values in the macOS
Keychain, and no plaintext `.env` anywhere. A project is a portable **name**,
not a path: the manifest holds ids, environments and sync config and no
absolute path at all, so copying it to another machine is the supported way to
take your projects with you. Each of its environments has two layers: `synced`,
which `pb env pull` replaces wholesale from Infisical, and `local`, which you
set by hand and which wins on merge. Those are `.env.local` semantics, and
they only hold because **patchbay never pushes** — there is no code path that
writes a variable to a remote, so a local override is invisible to the cloud
by construction rather than by policy, and a pull can never carry your
container's `DATABASE_URL` into the team's shared set. Values are stored one
Keychain item per project × environment × layer (account
`env:<project>/<env>/<local|synced>`), holding the whole layer as one JSON
blob, so an export is one Keychain round trip and not one per variable. No
`last4` is recorded: four characters of `true` or `5432` is not a hint, it is
the value. `pb env pull` also pins the account a project belongs to and checks
it before spending a subprocess — the Infisical CLI's active login is
machine-global, and under the wrong one the API answers 403 with "project does
not belong to your selected organization", which reads like a problem with the
project rather than with the login; patchbay refuses first and names
`pb use infisical <email>` instead.
- **Two ways a directory resolves to a project**, in that order. An
**attachment** (`pb env attach <id>` / `pb env detach`) binds a directory on
this machine, in `~/.config/patchbay/attachments.json` — deepest attached
ancestor wins, several roots per project, so every worktree and second clone
shares one vault. A **marker** — a committed `.patchbay.toml` holding
`project = "<id>"`, written by `pb env init` unless `--no-marker` — resolves
a checkout by its content, so a fresh `git clone` works on any machine whose
registry holds that project, with no attach step. An attachment always beats a
marker: a deliberate local act outranks whatever the repo ships, and nothing
in a repo can take that override back. A marker can only *name* a project the
machine already has, and one that names an unknown project is a loud error
pointing at the machine's `projects.json` rather than a silent miss. The
tradeoff, taken deliberately: repo content selects which registered project's
variables the tooling hands out, which assumes you run repos you trust.
- **Moving to a new machine** is therefore: bring `projects.json` over, clone the
repo (the marker travels with it), `pb env pull`. `pb export` carries that
manifest inside the bundle and `pb import` registers what is not here yet, so
the migration path is the normal route and copying the file by hand is the
fallback; a project id the destination already has is skipped with a note
rather than overwritten, because the machine in front of you may be the newer
one. What does **not** travel: `attachments.json`, since those are paths from
a machine that is not this one; every variable value in either layer; and the
local layer's variable *names* along with them, because a name with no value
behind it would make `pb env list` promise what `pb env run` could not
deliver. `SETUP.md`, the `pb plan` checklist and the `plan_setup` MCP tool
each carry one `pb env pull --project <id>` per linked project, marked
`auto: false` and naming the pinned account — a pull under the wrong
machine-global infisical login fails confusingly, so `pb use infisical
<email>` may have to come first. A project the old machine had *unlinked* but
with a synced layer is a gap instead: nothing on the new machine can rebuild
it.
- **`pb env`** — `init` (registers the project, attaches this directory, leaves
a marker to commit, picking up `.infisical.json`), `attach`, `detach`,
`link`, `projects`, `list`, `pull`, `set`, `unset`, `import`, `diff`, `run`,
`export`, `forget`. `init` in a worktree of a project this machine already
knows attaches it instead of failing on the duplicate id; `forget` takes the
project, its Keychain blobs and this machine's attachments, and leaves
committed markers alone (`rm .patchbay.toml`). `list` and `diff` answer from
the name lists alone and never touch the Keychain; `set` takes its value from
stdin or a hidden prompt, never argv; `run -- <cmd>` injects the merged
environment into one child process and is the blessed read path, with
`export` (dotenv or JSON, TTY warning) there for the cases where a file is
genuinely what you need.
`import <file>` bulk-loads an existing `.env` into the local layer,
all-or-nothing, reporting a bad line by number and never by content.
- **MCP tools** — `list_env_projects`, `list_env_vars`, `pull_env` and
`set_env_var`. `list_env_projects` reports each project's machine-local
`roots` alongside its environments, and says what an empty list means, so an
agent does not read a path there as where the user is working. The first two
are metadata only; `pull_env` executes the Infisical CLI but its outcome
carries counts and names, not values, so it is ungated;
`set_env_var` is open like `store_key`, so an agent that creates a
project credential registers it. Nothing reads a value back — not even behind
`PATCHBAY_ALLOW_SECRET_READ`. An environment is dozens of secrets at once,
and `pb env run` in your own terminal is the answer instead.

## [0.2.0] - 2026-08-13

### Added
Expand Down Expand Up @@ -119,73 +198,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
values, since those fields routinely hold API keys. A `copy` does carry values
(a server that cannot authenticate is useless) and names what travelled.

- **The project env vault** ([`pb env`](docs/env-vault.md)) — the variables a
*project* needs, held the way the key vault holds credentials: names and
provenance in `~/.config/patchbay/projects.json` (`0600`), values in the macOS
Keychain, and no plaintext `.env` anywhere. A project is a portable **name**,
not a path: the manifest holds ids, environments and sync config and no
absolute path at all, so copying it to another machine is the supported way to
take your projects with you. Each of its environments has two layers: `synced`,
which `pb env pull` replaces wholesale from Infisical, and `local`, which you
set by hand and which wins on merge. Those are `.env.local` semantics, and
they only hold because **patchbay never pushes** — there is no code path that
writes a variable to a remote, so a local override is invisible to the cloud
by construction rather than by policy, and a pull can never carry your
container's `DATABASE_URL` into the team's shared set. Values are stored one
Keychain item per project × environment × layer (account
`env:<project>/<env>/<local|synced>`), holding the whole layer as one JSON
blob, so an export is one Keychain round trip and not one per variable. No
`last4` is recorded: four characters of `true` or `5432` is not a hint, it is
the value. `pb env pull` also pins the account a project belongs to and checks
it before spending a subprocess — the Infisical CLI's active login is
machine-global, and under the wrong one the API answers 403 with "project does
not belong to your selected organization", which reads like a problem with the
project rather than with the login; patchbay refuses first and names
`pb use infisical <email>` instead.
- **Two ways a directory resolves to a project**, in that order. An
**attachment** (`pb env attach <id>` / `pb env detach`) binds a directory on
this machine, in `~/.config/patchbay/attachments.json` — deepest attached
ancestor wins, several roots per project, so every worktree and second clone
shares one vault. A **marker** — a committed `.patchbay.toml` holding
`project = "<id>"`, written by `pb env init` unless `--no-marker` — resolves
a checkout by its content, so a fresh `git clone` works on any machine whose
registry holds that project, with no attach step. An attachment always beats a
marker: a deliberate local act outranks whatever the repo ships, and nothing
in a repo can take that override back. A marker can only *name* a project the
machine already has, and one that names an unknown project is a loud error
pointing at the machine's `projects.json` rather than a silent miss. The
tradeoff, taken deliberately: repo content selects which registered project's
variables the tooling hands out, which assumes you run repos you trust.
- **Moving to a new machine** is therefore: copy `projects.json`, clone the repo
(the marker travels with it), `pb env pull`. Attachments deliberately do not
travel — they are paths from a machine that is not this one — and neither does
the local layer, since a `DATABASE_URL` pointing at a container on the old
laptop is exactly what must not follow you.
- **`pb env`** — `init` (registers the project, attaches this directory, leaves
a marker to commit, picking up `.infisical.json`), `attach`, `detach`,
`link`, `projects`, `list`, `pull`, `set`, `unset`, `import`, `diff`, `run`,
`export`, `forget`. `init` in a worktree of a project this machine already
knows attaches it instead of failing on the duplicate id; `forget` takes the
project, its Keychain blobs and this machine's attachments, and leaves
committed markers alone (`rm .patchbay.toml`). `list` and `diff` answer from
the name lists alone and never touch the Keychain; `set` takes its value from
stdin or a hidden prompt, never argv; `run -- <cmd>` injects the merged
environment into one child process and is the blessed read path, with
`export` (dotenv or JSON, TTY warning) there for the cases where a file is
genuinely what you need.
`import <file>` bulk-loads an existing `.env` into the local layer,
all-or-nothing, reporting a bad line by number and never by content.
- **MCP tools** — `list_env_projects`, `list_env_vars`, `pull_env` and
`set_env_var`. `list_env_projects` reports each project's machine-local
`roots` alongside its environments, and says what an empty list means, so an
agent does not read a path there as where the user is working. The first two
are metadata only; `pull_env` executes the Infisical CLI but its outcome
carries counts and names, not values, so it is ungated;
`set_env_var` is open like `store_key`, so an agent that creates a
project credential registers it. Nothing reads a value back — not even behind
`PATCHBAY_ALLOW_SECRET_READ`. An environment is dozens of secrets at once,
and `pb env run` in your own terminal is the answer instead.

### Changed

- `patchbay_core::util` now owns the write-safety machinery MCP client
Expand Down
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
- **Permissions** — see what your tokens can actually do (`gh` scopes today) and fix missing scopes with one hint.
- **[MCP client management](docs/mcp-clients.md)** — every MCP server registered in Claude Code, Claude Desktop, Cursor, Codex, Windsurf and VS Code in one matrix; copy a server between clients without hand-editing four files in two formats.
- **[Key vault](docs/key-vault.md)** — standalone API keys no CLI tracks: values in the macOS Keychain, metadata on disk, provider-aware `pb key verify`, and AI registration over MCP.
- **[Project env vault](docs/env-vault.md)** — a project's environment variables without a plaintext `.env`: pull from Infisical, keep hand-set local overrides that never sync back, run a command with the merged result. A project is a portable name, not a path — copy one file to a new machine, clone the repo, pull.
- **[Project env vault](docs/env-vault.md)** — a project's environment variables without a plaintext `.env`: pull from Infisical, keep hand-set local overrides that never sync back, run a command with the merged result. A project is a portable name, not a path — `pb export` carries the manifest to a new machine (or copy the one file), clone the repo, pull.
- **[Keeping CLIs current](#keeping-clis-current)** — which tools are outdated, which were renamed out from under you, and the exact command to update each one.
- **Migrate** — export to a new machine; whatever can't travel, your AI walks you through re-authing.
- **[Migrate](docs/migration.md)** — export to a new machine; whatever can't travel, your AI walks you through re-authing.
Expand Down Expand Up @@ -102,6 +102,11 @@ each becomes one line with the command that fixes it. Your AI can work that list
over MCP (`plan_setup`, `mark_setup_done`), and patchbay re-probes after every
step rather than believing it.

The [env vault](docs/env-vault.md)'s projects ride along as metadata — ids,
environments and sync pins, so the new machine knows what to pull. No variable
value travels, in either layer, and neither does this machine's list of which
directories belong to which project.

Encrypted with a passphrase, refuses to be written into a cloud-sync folder, and
never copies a private key. **[Full details, and the per-tool portability
table →](docs/migration.md)**
Expand Down
2 changes: 2 additions & 0 deletions crates/patchbay-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -155,10 +155,12 @@ fn run() -> Result<i32> {
if let Some(manifest) = diff {
let vault = KeyRegistry::detect()?;
let clients = McpClientRegistry::with_paths(registry.paths().clone());
let envs = patchbay_core::EnvRegistry::detect()?;
return migrate::print_status_diff(
&registry,
&vault,
&clients,
&envs,
&manifest,
&styles(),
);
Expand Down
47 changes: 44 additions & 3 deletions crates/patchbay-cli/src/migrate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ use patchbay_core::migrate::{
self, export, import, manifest::SetupStatus, Exporter, ImportOptions, Importer, KeySelection,
Manifest, SetupItem,
};
use patchbay_core::{KeyRegistry, McpClientRegistry, Registry};
use patchbay_core::{EnvRegistry, KeyRegistry, McpClientRegistry, Registry};

use crate::render::{self, Styles};

Expand Down Expand Up @@ -69,6 +69,7 @@ pub fn run(command: Command, styles: &Styles) -> Result<i32> {
let paths = registry.paths().clone();
let vault = KeyRegistry::detect()?;
let clients = McpClientRegistry::with_paths(paths.clone());
let envs = EnvRegistry::detect()?;

match command {
Command::Export {
Expand All @@ -88,6 +89,7 @@ pub fn run(command: Command, styles: &Styles) -> Result<i32> {
registry: &registry,
vault: &vault,
clients: &clients,
envs: &envs,
}
.payload(&selection, Utc::now())?;

Expand Down Expand Up @@ -120,6 +122,7 @@ pub fn run(command: Command, styles: &Styles) -> Result<i32> {
registry: &registry,
vault: &vault,
clients: &clients,
envs: &envs,
}
.run(&payload, &ImportOptions { dry_run })?;

Expand All @@ -137,7 +140,14 @@ pub fn run(command: Command, styles: &Styles) -> Result<i32> {
json,
} => {
let manifest = manifest.as_deref().map(read_manifest).transpose()?;
let items = migrate::plan(&paths, &registry, &vault, &clients, manifest.as_ref());
let items = migrate::plan(
&paths,
&registry,
&vault,
&clients,
&envs,
manifest.as_ref(),
);
let shown: Vec<&SetupItem> = items
.iter()
.filter(|i| all || i.status != SetupStatus::Done)
Expand All @@ -160,11 +170,19 @@ pub fn print_status_diff(
registry: &Registry,
vault: &KeyRegistry,
clients: &McpClientRegistry,
envs: &EnvRegistry,
manifest: &std::path::Path,
styles: &Styles,
) -> Result<i32> {
let manifest = read_manifest(manifest)?;
let items = migrate::plan(registry.paths(), registry, vault, clients, Some(&manifest));
let items = migrate::plan(
registry.paths(),
registry,
vault,
clients,
envs,
Some(&manifest),
);
let open: Vec<&SetupItem> = items.iter().filter(|i| i.is_open()).collect();
println!(
"{} of {} things the other machine had are not true here",
Expand Down Expand Up @@ -266,6 +284,15 @@ fn print_export(report: &export::ExportReport, styles: &Styles) {
);
}
}
if !report.env_projects.is_empty() {
// Metadata only, and saying so here is the point: an env project in a
// bundle is a name, not a set of variables.
println!(
" env: {} project(s), names only ({})",
report.env_projects.len(),
report.env_projects.join(", ")
);
}
println!(
" {} item(s) will need doing on the new machine",
report.gaps
Expand Down Expand Up @@ -313,6 +340,16 @@ fn print_import(report: &import::ImportReport, styles: &Styles) {
server.name
);
}
for project in &report.env_projects {
println!(
" {:<10} env project {}",
project.outcome.label(),
project.id
);
if let import::FileOutcome::Skipped { reason } = &project.outcome {
println!(" {reason}");
}
}
println!();
for note in &report.notes {
println!("{}", styles.paint(warn_style(), &format!("! {note}")));
Expand Down Expand Up @@ -397,6 +434,7 @@ fn export_json(report: &export::ExportReport) -> serde_json::Value {
"keys_listed_only": report.keys_listed,
"mcp_servers": report.mcp_carried,
"mcp_value_names_carried": report.mcp_values_carried,
"env_projects": report.env_projects,
"gaps": report.gaps,
"warnings": report.warnings,
})
Expand Down Expand Up @@ -427,6 +465,9 @@ fn import_json(report: &import::ImportReport) -> serde_json::Value {
"action": m.outcome.label(),
"value_names_carried": m.values_carried,
})).collect::<Vec<_>>(),
"env_projects": report.env_projects.iter().map(|p| serde_json::json!({
"id": p.id, "action": p.outcome.label(),
})).collect::<Vec<_>>(),
"notes": report.notes,
"remaining": report.remaining,
})
Expand Down
Loading
Loading