From 39bb78f0645ecb46ec8307281e0af4b5b505d744 Mon Sep 17 00:00:00 2001 From: YJack0000 Date: Thu, 13 Aug 2026 23:54:00 +0800 Subject: [PATCH 1/3] [feature] project env vault: pb env with synced/local layers, pull-only Infisical sync, account guard --- CHANGELOG.md | 39 + README.md | 3 + crates/patchbay-cli/src/env.rs | 1413 +++++++++++++ crates/patchbay-cli/src/main.rs | 10 + crates/patchbay-cli/src/mcp.rs | 27 +- crates/patchbay-cli/src/render.rs | 35 + crates/patchbay-core/src/env_sync.rs | 619 ++++++ crates/patchbay-core/src/envs.rs | 1880 ++++++++++++++++++ crates/patchbay-core/src/keys.rs | 48 +- crates/patchbay-core/src/lib.rs | 11 +- crates/patchbay-core/src/paths.rs | 12 + crates/patchbay-core/src/probes/infisical.rs | 43 + crates/patchbay-mcp/src/envs.rs | 658 ++++++ crates/patchbay-mcp/src/main.rs | 6 +- crates/patchbay-mcp/src/server.rs | 37 +- docs/env-vault.md | 294 +++ 16 files changed, 5096 insertions(+), 39 deletions(-) create mode 100644 crates/patchbay-cli/src/env.rs create mode 100644 crates/patchbay-core/src/env_sync.rs create mode 100644 crates/patchbay-core/src/envs.rs create mode 100644 crates/patchbay-mcp/src/envs.rs create mode 100644 docs/env-vault.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f0035d..9212d29 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -100,6 +100,45 @@ 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 + *directory* 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 registered + directory, resolved from the cwd by walking up to the nearest root (deepest + wins in a monorepo), and 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://`), 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 ` instead. +- **`pb env`** — `init` (registers a directory, picking up `.infisical.json`), + `link`, `projects`, `list`, `pull`, `set`, `unset`, `import`, `diff`, `run`, + `export`, `forget`. `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 -- ` 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 ` 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`. 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 diff --git a/README.md b/README.md index b83ac44..acd5918 100644 --- a/README.md +++ b/README.md @@ -28,6 +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 directory'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. - **[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. @@ -52,6 +53,7 @@ pb status # the whole board in your terminal pb use gcloud work # switch a profile pb verify gh # actually check a token against its API pb key list # your registered API keys +pb env run -- bun dev # this directory's env vars, from the Keychain, no .env file ``` Or just open the panel: search with `/`, filter by category or connection state, click a card to operate that tool. @@ -129,6 +131,7 @@ cd app && bun install && bun run tauri dev # the panel (Tauri 2 + React - [Moving to a new machine](docs/migration.md) - [Key vault — security model](docs/key-vault.md) +- [Project env vault — two layers, pull-only](docs/env-vault.md) - [MCP client management](docs/mcp-clients.md) - [Contributing & development](CONTRIBUTING.md) - [Changelog](CHANGELOG.md) diff --git a/crates/patchbay-cli/src/env.rs b/crates/patchbay-cli/src/env.rs new file mode 100644 index 0000000..1bc9833 --- /dev/null +++ b/crates/patchbay-cli/src/env.rs @@ -0,0 +1,1413 @@ +//! `pb env …` — the project env vault in the terminal. +//! +//! One directory, two layers per environment: `synced` is whatever the last +//! pull took from Infisical, `local` is what this machine set by hand. Local +//! wins on merge, survives every pull, and never leaves the box — patchbay has +//! no push, here or anywhere else. +//! +//! Values may reach exactly two places: the environment of the child process +//! [`Command::Run`] spawns, and the stdout of [`Command::Export`]. Never argv, +//! never a table, never an error message. `list` and `diff` are name-only by +//! construction — they read the metadata file and do not touch the keychain at +//! all, which is also what makes them fast enough to put in a shell hook. + +use std::collections::BTreeMap; +use std::ffi::OsString; +use std::io::{IsTerminal, Read, Write}; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; +use chrono::{DateTime, Utc}; +use clap::{Args, Subcommand, ValueEnum}; +use patchbay_core::envs::{ + parse_dotenv, render_dotenv, validate_project_id, EnvRegistry, EnvVarInfo, EnvVarSource, + ProjectEntry, SyncConfig, DEFAULT_ENV, +}; +use patchbay_core::paths::Paths; +use patchbay_core::probes::infisical; + +use crate::render::{self, Styles}; + +/// Width budget for the tables, matching the status board. +const TABLE_WIDTH: usize = 100; +const GAP: usize = 2; +const COL_ID_MAX: usize = 24; +const COL_ENVS_MAX: usize = 22; +const COL_SYNC_MAX: usize = 32; +const COL_NAME_MAX: usize = 40; +/// Wide enough for `local override`, the longest source label there is. +const COL_SOURCE: usize = 14; +const DASH: &str = "—"; + +/// The file the infisical CLI drops in a linked repo. +const INFISICAL_FILE: &str = ".infisical.json"; + +#[derive(Subcommand, Debug)] +pub enum Command { + /// Register a directory as a project. + /// + /// Reads `.infisical.json` if the directory has one, and records the sync + /// automatically when this machine is logged in to infisical. + Init { + /// Project id. Defaults to the directory's own name as a slug. + #[arg(long, value_name = "SLUG")] + id: Option, + /// The directory to register. Defaults to the current one. + #[arg(long, value_name = "PATH")] + dir: Option, + /// Environment `pb env` uses when a command does not say. + #[arg(long, value_name = "ENV", default_value = DEFAULT_ENV)] + default_env: String, + }, + /// Point a project at an Infisical project, replacing any earlier link. + Link { + /// Infisical's own project id (a UUID), from `.infisical.json` or the + /// project URL. + #[arg(long, value_name = "ID")] + project_id: String, + /// patchbay project to link. Defaults to this directory's. + #[arg(long, value_name = "ID")] + project: Option, + /// Account the pull must run as. Defaults to the active infisical login. + #[arg(long, value_name = "EMAIL")] + account: Option, + /// API base URL, for self-hosted or EU instances. + #[arg(long, value_name = "URL")] + domain: Option, + /// Environment name mapping, for remotes that spell them differently: + /// `--map production=prod,dev=development`. + #[arg(long, value_delimiter = ',', value_name = "LOCAL=REMOTE")] + map: Vec, + }, + /// Every registered project. + Projects { + #[arg(long)] + json: bool, + }, + /// Variable names in one environment. Metadata only — never values. + List { + #[command(flatten)] + target: Target, + #[arg(long)] + json: bool, + }, + /// Replace the synced layer from the remote. The local layer is untouched. + Pull { + #[command(flatten)] + target: Target, + #[arg(long)] + json: bool, + }, + /// Set one variable in the local layer. + /// + /// The value is read from stdin when something is piped in, and from a + /// hidden prompt otherwise. It is never taken as an argument. + Set { + name: String, + #[command(flatten)] + target: Target, + }, + /// Remove one variable from the local layer. + Unset { + name: String, + #[command(flatten)] + target: Target, + }, + /// Merge a `.env` file into the local layer. `-` reads stdin. + Import { + #[arg(value_name = "FILE")] + file: PathBuf, + #[command(flatten)] + target: Target, + }, + /// Which names are overridden, local-only or synced-only. Names only. + Diff { + #[command(flatten)] + target: Target, + #[arg(long)] + json: bool, + }, + /// Run a command with the merged environment applied. + Run { + #[command(flatten)] + target: Target, + /// The command, after `--`: `pb env run -- npm run dev`. + #[arg(trailing_var_arg = true, required = true, value_name = "CMD")] + command: Vec, + }, + /// Print the merged environment. **This is the one command that prints + /// values** — redirect it, or prefer `pb env run`. + Export { + #[command(flatten)] + target: Target, + #[arg(long, value_enum, default_value_t = ExportFormat::Dotenv)] + format: ExportFormat, + }, + /// Unregister a project: metadata entry and every stored value. + Forget { + /// Project to forget. Defaults to this directory's. + #[arg(long, value_name = "ID")] + project: Option, + /// Skip the confirmation prompt. + #[arg(long)] + yes: bool, + }, +} + +/// Which project and environment a subcommand acts on. +#[derive(Args, Debug, Default)] +pub struct Target { + /// Project id, as `pb env projects` lists it. Defaults to the project this + /// directory belongs to. + #[arg(long, value_name = "ID")] + project: Option, + /// Environment name. Defaults to the project's own default. + #[arg(short, long, value_name = "ENV")] + env: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)] +pub enum ExportFormat { + /// `NAME='value'` lines, as a `.env` file. + Dotenv, + /// A flat `{"NAME": "value"}` object. + Json, +} + +/// Returns the process exit code. +pub fn run(command: Command, styles: &Styles) -> Result { + let registry = EnvRegistry::detect()?; + + match command { + Command::Init { + id, + dir, + default_env, + } => { + let root = absolute_dir(dir)?; + let id = match id { + Some(id) => id, + None => default_project_id(&root)?, + }; + let entry = registry.register(&id, &root, &default_env)?; + + println!("registered {}", entry.id); + println!(" root: {}", render::tilde(&entry.root)); + println!(" default env: {}", entry.default_env); + println!(" metadata: {}", registry.path().display()); + print_adopted_sync(®istry, &entry)?; + Ok(0) + } + + Command::Link { + project_id, + project, + account, + domain, + map, + } => { + let entry = resolve_project(®istry, project.as_deref())?; + let account = match account { + Some(account) => account, + None => { + let paths = Paths::detect()?; + infisical::active_account(&paths)?.ok_or_else(|| { + anyhow::anyhow!("no infisical login to pin; pass --account ") + })? + } + }; + let updated = registry.set_sync( + &entry.id, + SyncConfig { + provider: "infisical".to_string(), + project_id, + account, + domain, + env_map: parse_env_map(&map)?, + }, + )?; + + println!("linked {}", updated.id); + print_sync(&updated); + Ok(0) + } + + Command::Projects { json } => { + let projects = registry.projects()?; + if json { + // Machine-readable: JSON only, no ANSI, no extras. + println!("{}", serde_json::to_string_pretty(&projects)?); + return Ok(0); + } + if projects.is_empty() { + println!("no projects registered yet"); + println!(" pb env init (in the directory you want to register)"); + return Ok(0); + } + print!("{}", render_projects(&projects, styles)); + Ok(0) + } + + Command::List { target, json } => { + let (project, env) = resolve(®istry, &target)?; + // Names and provenance only: this never opens the keychain. + let vars = registry.list(&project.id, &env)?; + if json { + println!("{}", serde_json::to_string_pretty(&vars)?); + return Ok(0); + } + let synced_at = project.env(&env).and_then(|meta| meta.synced_at); + print!( + "{}", + render_list(&project.id, &env, &vars, synced_at, Utc::now(), styles) + ); + Ok(0) + } + + Command::Pull { target, json } => { + let (project, env) = resolve(®istry, &target)?; + let paths = Paths::detect()?; + let outcome = patchbay_core::env_sync::pull(&paths, ®istry, &project, &env)?; + + if json { + println!("{}", serde_json::to_string_pretty(&outcome)?); + return Ok(0); + } + println!( + "pulled {} variable{} into {}/{}", + outcome.count, + plural(outcome.count), + project.id, + outcome.env + ); + println!(" remote environment: {}", outcome.remote_env); + for note in &outcome.notes { + println!(" note: {note}"); + } + println!(" the local layer was not touched — it never is"); + Ok(0) + } + + Command::Set { name, target } => { + let (project, env) = resolve(®istry, &target)?; + let value = read_value(&name, &project.id, &env)?; + registry.set_local(&project.id, &env, &name, &value)?; + drop(value); + + println!( + "set {name} in {}/{env} (local layer — never synced)", + project.id + ); + println!(" it survives every `pb env pull`, and patchbay never pushes it anywhere"); + Ok(0) + } + + Command::Unset { name, target } => { + let (project, env) = resolve(®istry, &target)?; + let note = registry.unset_local(&project.id, &env, &name)?; + println!("unset {name} in {}/{env} (local layer)", project.id); + if let Some(note) = note { + println!(" note: {note}"); + } + Ok(0) + } + + Command::Import { file, target } => { + let (project, env) = resolve(®istry, &target)?; + let text = read_dotenv_file(&file)?; + let source = describe_source(&file); + // The parser names line numbers and never the line: a line it could + // not read is, by definition, a string patchbay does not understand. + let vars = parse_dotenv(&text) + .map_err(|e| anyhow::anyhow!("could not read {source} as a .env file: {e}"))?; + let count = registry.import_local(&project.id, &env, &vars)?; + + if count == 0 { + println!("{source} held no variables; nothing was imported"); + return Ok(0); + } + println!( + "imported {count} variable{} into {}/{env} (local layer)", + plural(count), + project.id + ); + println!(" patchbay never pushes them anywhere — they stay on this machine"); + Ok(0) + } + + Command::Diff { target, json } => { + let (project, env) = resolve(®istry, &target)?; + let sections = Diff::of(®istry.list(&project.id, &env)?); + if json { + println!( + "{}", + serde_json::to_string_pretty(&serde_json::json!({ + "project": project.id, + "env": env, + "overrides": sections.overrides, + "local_only": sections.local_only, + "synced_only": sections.synced_only, + }))? + ); + return Ok(0); + } + print!("{}", render_diff(&project.id, &env, §ions, styles)); + Ok(0) + } + + Command::Run { target, command } => { + let (project, env) = resolve(®istry, &target)?; + let merged = registry.merged(&project.id, &env)?; + let (bin, args) = command + .split_first() + .ok_or_else(|| anyhow::anyhow!("nothing to run; pass a command after `--`"))?; + + // stderr, so a command whose stdout is being piped stays clean. + eprintln!( + "injecting {} var{} into `{bin}` ({}/{env}: {} synced, {} local)", + merged.vars.len(), + plural(merged.vars.len()), + project.id, + merged.from_synced.len(), + merged.from_local.len() + ); + + let mut child = std::process::Command::new(bin); + child.args(args); + child + .env_clear() + .envs(child_env(std::env::vars_os().collect(), &merged.vars)); + let status = child + .status() + .with_context(|| format!("could not run `{bin}`"))?; + + match status.code() { + Some(code) => Ok(code), + None => { + eprintln!("pb: `{bin}` was killed by a signal"); + Ok(1) + } + } + } + + Command::Export { target, format } => { + let (project, env) = resolve(®istry, &target)?; + let merged = registry.merged(&project.id, &env)?; + + // The warning goes to stderr so a redirect still produces a clean + // file, and it comes first so it is on screen before the values are. + if std::io::stdout().is_terminal() { + eprintln!( + "this prints secret values to your terminal; prefer `pb env run -- `, \ + or redirect: pb env export > .env" + ); + } + match format { + ExportFormat::Dotenv => print!("{}", render_dotenv(&merged.vars)), + ExportFormat::Json => println!("{}", render_json(&merged.vars)?), + } + Ok(0) + } + + Command::Forget { project, yes } => { + let entry = resolve_project(®istry, project.as_deref())?; + if !yes && !confirm(&entry)? { + println!("left {} alone", entry.id); + return Ok(0); + } + let removed = registry.forget(&entry.id)?; + + println!("forgot {} ({})", removed.id, render::tilde(&removed.root)); + println!( + " its stored values are gone from the {}", + registry.store_name() + ); + println!(" nothing was revoked — whatever the remote holds is untouched"); + Ok(0) + } + } +} + +// --------------------------------------------------------------------------- +// resolution +// --------------------------------------------------------------------------- + +/// The project and environment a command acts on: the flags if given, this +/// directory's project and its own default otherwise. +fn resolve(registry: &EnvRegistry, target: &Target) -> Result<(ProjectEntry, String)> { + let project = resolve_project(registry, target.project.as_deref())?; + let env = target + .env + .clone() + .unwrap_or_else(|| project.default_env.clone()); + Ok((project, env)) +} + +fn resolve_project(registry: &EnvRegistry, id: Option<&str>) -> Result { + if let Some(id) = id { + return registry.get(id)?.ok_or_else(|| { + anyhow::anyhow!("no project registered as `{id}`; `pb env projects` lists them") + }); + } + let dir = std::env::current_dir().context("could not read the current directory")?; + registry.find_by_dir(&dir)?.ok_or_else(|| { + anyhow::anyhow!( + "no project registered for this directory; run `pb env init` here, or pass \ + --project (pb env projects lists them)" + ) + }) +} + +/// `--dir` made absolute, or the current directory. Relative paths are joined +/// onto the cwd rather than canonicalized: the registry compares roots by path +/// prefix, and resolving symlinks here would make the answer depend on the +/// filesystem's mood. +fn absolute_dir(dir: Option) -> Result { + let raw = match dir { + Some(dir) => dir, + None => return std::env::current_dir().context("could not read the current directory"), + }; + let joined = if raw.is_absolute() { + raw + } else { + std::env::current_dir() + .context("could not read the current directory")? + .join(raw) + }; + // Drops `.` components, so `--dir .` records the directory, not `…/.`. + Ok(joined.components().collect()) +} + +/// The default id for a directory: its own name, lowercased, with everything a +/// slug cannot hold folded to `-`. +fn default_project_id(root: &Path) -> Result { + let slug = slugify_dir_name(root).ok_or_else(|| { + anyhow::anyhow!( + "{} has no directory name to take an id from; pass --id ", + root.display() + ) + })?; + validate_project_id(&slug) + .map_err(|e| anyhow::anyhow!("{e}; pass --id to name this project yourself"))?; + Ok(slug) +} + +fn slugify_dir_name(root: &Path) -> Option { + let name = root.file_name()?.to_string_lossy().to_ascii_lowercase(); + if name.is_empty() { + return None; + } + Some( + name.chars() + .map(|c| { + if c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '.' | '_' | '-') { + c + } else { + '-' + } + }) + .collect(), + ) +} + +/// `--map production=prod,dev=development` as a patchbay→remote name map. +fn parse_env_map(pairs: &[String]) -> Result> { + let mut map = BTreeMap::new(); + for pair in pairs { + let pair = pair.trim(); + // A trailing comma is a typo, not an instruction. + if pair.is_empty() { + continue; + } + let (local, remote) = pair.split_once('=').ok_or_else(|| { + anyhow::anyhow!( + "`{pair}` is not `local=remote`; --map takes comma-separated pairs, \ + e.g. --map production=prod" + ) + })?; + let (local, remote) = (local.trim(), remote.trim()); + if local.is_empty() || remote.is_empty() { + anyhow::bail!("`{pair}` has an empty side; --map takes `local=remote` pairs"); + } + map.insert(local.to_string(), remote.to_string()); + } + Ok(map) +} + +// --------------------------------------------------------------------------- +// .infisical.json +// --------------------------------------------------------------------------- + +/// The two fields patchbay reads out of a repo's `.infisical.json`. +#[derive(Debug, Default, PartialEq, Eq)] +struct InfisicalProject { + workspace_id: Option, + default_environment: Option, +} + +/// Read those two fields, ignoring everything else in the file — it holds +/// several more keys and they change between CLI releases. +fn parse_infisical_json(text: &str) -> Result { + let value: serde_json::Value = + serde_json::from_str(text).map_err(|e| anyhow::anyhow!("it is not valid JSON ({e})"))?; + if !value.is_object() { + anyhow::bail!("it is not a JSON object"); + } + let field = |name: &str| { + value + .get(name) + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) + }; + Ok(InfisicalProject { + workspace_id: field("workspaceId"), + default_environment: field("defaultEnvironment"), + }) +} + +/// Record the sync a freshly registered project's `.infisical.json` implies, +/// and say what happened either way. +/// +/// A malformed file is a note, not a failure: the project is registered, and +/// `pb env link` can do by hand what this could not do automatically. +fn print_adopted_sync(registry: &EnvRegistry, entry: &ProjectEntry) -> Result<()> { + let path = entry.root.join(INFISICAL_FILE); + let Ok(text) = std::fs::read_to_string(&path) else { + println!(" hint: link it with `pb env link --project-id `"); + return Ok(()); + }; + let file = match parse_infisical_json(&text) { + Ok(file) => file, + Err(e) => { + println!(" note: {INFISICAL_FILE} is here but patchbay could not read it: {e}"); + println!(" hint: link it with `pb env link --project-id `"); + return Ok(()); + } + }; + + match (&file.workspace_id, active_account_or_none()) { + (Some(workspace_id), Some(account)) => { + let linked = registry.set_sync( + &entry.id, + SyncConfig { + provider: "infisical".to_string(), + project_id: workspace_id.clone(), + account, + domain: None, + env_map: BTreeMap::new(), + }, + )?; + println!(" read {INFISICAL_FILE} in the project root"); + print_sync(&linked); + } + (Some(workspace_id), None) => { + println!(" read {INFISICAL_FILE}: infisical project {workspace_id}"); + println!( + " no infisical login on this machine to pin it to, so nothing was linked; \ + run `infisical login`, then `pb env link --project-id {workspace_id}`" + ); + } + (None, _) => { + println!(" note: {INFISICAL_FILE} names no workspaceId"); + println!(" hint: link it with `pb env link --project-id `"); + } + } + + // Applying the remote's own default silently would mean a `pb env pull` + // that quietly reads a different environment than the one it names. + if let Some(remote_default) = &file.default_environment { + if remote_default != &entry.default_env { + println!( + " hint: {INFISICAL_FILE} calls its default environment `{remote_default}`; if \ + that is this project's `{}`, record it with `pb env link --project-id \ + --map {}={remote_default}`", + entry.default_env, entry.default_env + ); + } + } + Ok(()) +} + +/// The active infisical login, or `None` when there is none *or* when the +/// machine cannot be asked. Neither is a reason to fail a registration. +fn active_account_or_none() -> Option { + let paths = Paths::detect().ok()?; + infisical::active_account(&paths).ok().flatten() +} + +fn print_sync(entry: &ProjectEntry) { + let Some(sync) = &entry.sync else { + return; + }; + println!(" sync: {} {}", sync.provider, sync.project_id); + println!(" account: {}", sync.account); + if let Some(domain) = &sync.domain { + println!(" domain: {domain}"); + } + if !sync.env_map.is_empty() { + let pairs: Vec = sync + .env_map + .iter() + .map(|(local, remote)| format!("{local}→{remote}")) + .collect(); + println!(" env map: {}", pairs.join(", ")); + } + println!( + " the sync is pinned to this account: pulls will refuse under any other infisical login" + ); +} + +// --------------------------------------------------------------------------- +// input +// --------------------------------------------------------------------------- + +/// Read the value from a pipe, or prompt for it without echo. Never argv: +/// that is world-readable through `ps` and lands in `~/.zsh_history` verbatim. +fn read_value(name: &str, project: &str, env: &str) -> Result { + let stdin = std::io::stdin(); + if stdin.is_terminal() { + let value = rpassword::prompt_password(format!( + "value for {name} in {project}/{env} (not echoed): " + )) + .context("could not read the value from the terminal")?; + if value.is_empty() { + anyhow::bail!("no value entered"); + } + return Ok(value); + } + let mut buf = String::new(); + stdin + .lock() + .read_to_string(&mut buf) + .context("could not read the value from stdin")?; + let value = buf.trim_end_matches(['\n', '\r']).to_string(); + if value.is_empty() { + anyhow::bail!( + "nothing on stdin; pipe the value in, or run this from a terminal to be prompted" + ); + } + Ok(value) +} + +/// A `.env` file, or stdin for `-`. +fn read_dotenv_file(file: &Path) -> Result { + if file == Path::new("-") { + let mut buf = String::new(); + std::io::stdin() + .lock() + .read_to_string(&mut buf) + .context("could not read the .env from stdin")?; + return Ok(buf); + } + std::fs::read_to_string(file).with_context(|| format!("could not read {}", file.display())) +} + +fn describe_source(file: &Path) -> String { + if file == Path::new("-") { + "stdin".to_string() + } else { + file.display().to_string() + } +} + +/// `y`/`yes` on stdin. Anything else, including EOF, means no. +fn confirm(entry: &ProjectEntry) -> Result { + let envs = entry.environments.len(); + print!( + "forget {} ({envs} environment{}) and delete its stored values from the keychain? [y/N] ", + entry.id, + plural(envs) + ); + std::io::stdout().flush().ok(); + let mut answer = String::new(); + if std::io::stdin() + .read_line(&mut answer) + .context("could not read the answer")? + == 0 + { + println!(); + return Ok(false); + } + Ok(matches!( + answer.trim().to_ascii_lowercase().as_str(), + "y" | "yes" + )) +} + +// --------------------------------------------------------------------------- +// the child environment +// --------------------------------------------------------------------------- + +/// This process's environment with the vault's values laid over it. +/// +/// Built explicitly rather than left to `Command`'s own overlay so the result +/// is a value a test can inspect: the merged environment must win over an +/// inherited variable of the same name, which is the whole point of `pb env +/// run` in a shell that already exported a stale `DATABASE_URL`. +fn child_env( + inherited: Vec<(OsString, OsString)>, + merged: &BTreeMap, +) -> Vec<(OsString, OsString)> { + let mut out: Vec<(OsString, OsString)> = inherited + .into_iter() + .filter(|(name, _)| match name.to_str() { + Some(name) => !merged.contains_key(name), + // A name that is not UTF-8 cannot collide with a vault name, which + // the core validates as `[A-Za-z_][A-Za-z0-9_]*`. + None => true, + }) + .collect(); + out.extend( + merged + .iter() + .map(|(name, value)| (OsString::from(name), OsString::from(value))), + ); + out +} + +// --------------------------------------------------------------------------- +// output +// --------------------------------------------------------------------------- + +/// Names only, in three buckets. Values never enter this type. +#[derive(Debug, Default, PartialEq, Eq)] +struct Diff { + /// Local names shadowing a synced one. + overrides: Vec, + /// Set here and nowhere else. + local_only: Vec, + /// Pulled, and not overridden here. + synced_only: Vec, +} + +impl Diff { + fn of(vars: &[EnvVarInfo]) -> Self { + let take = |want: EnvVarSource| -> Vec { + vars.iter() + .filter(|v| v.source == want) + .map(|v| v.name.clone()) + .collect() + }; + Self { + overrides: take(EnvVarSource::LocalOverride), + local_only: take(EnvVarSource::Local), + synced_only: take(EnvVarSource::Synced), + } + } + + fn is_empty(&self) -> bool { + self.overrides.is_empty() && self.local_only.is_empty() && self.synced_only.is_empty() + } +} + +/// A flat `{"NAME": "value"}` object, built by hand: `MergedEnv` deliberately +/// does not derive `Serialize`, and this is the one place that is on purpose. +fn render_json(vars: &BTreeMap) -> Result { + let mut object = serde_json::Map::new(); + for (name, value) in vars { + object.insert(name.clone(), serde_json::Value::String(value.clone())); + } + Ok(serde_json::to_string_pretty(&serde_json::Value::Object( + object, + ))?) +} + +fn plural(n: usize) -> &'static str { + if n == 1 { + "" + } else { + "s" + } +} + +fn pad(s: &str, width: usize) -> String { + let len = s.chars().count(); + if len >= width { + s.to_string() + } else { + format!("{s}{}", " ".repeat(width - len)) + } +} + +/// Width of a column: the widest value in it, bounded by `max`, never narrower +/// than its header. +fn column_width(values: impl Iterator, header: &str, max: usize) -> usize { + values + .chain(std::iter::once(header.len())) + .max() + .unwrap_or(header.len()) + .min(max) + .max(header.len()) +} + +/// The project table. Roots are long, so ROOT takes whatever the other columns +/// leave and gets truncated into it. +pub fn render_projects(projects: &[ProjectEntry], styles: &Styles) -> String { + let sync_cell = |p: &ProjectEntry| match &p.sync { + Some(sync) => format!("{}:{}", sync.provider, sync.account), + None => DASH.to_string(), + }; + let envs_cell = |p: &ProjectEntry| { + let names = p.env_names(); + if names.is_empty() { + DASH.to_string() + } else { + names.join(",") + } + }; + + let id_w = column_width( + projects.iter().map(|p| p.id.chars().count()), + "ID", + COL_ID_MAX, + ); + let envs_w = column_width( + projects.iter().map(|p| envs_cell(p).chars().count()), + "ENVS", + COL_ENVS_MAX, + ); + let sync_w = column_width( + projects.iter().map(|p| sync_cell(p).chars().count()), + "SYNC", + COL_SYNC_MAX, + ); + let fixed = id_w + envs_w + sync_w + GAP * 3; + let root_w = TABLE_WIDTH.saturating_sub(fixed).max(16); + + let gap = " ".repeat(GAP); + let mut out = String::new(); + + let header = format!( + "{}{gap}{}{gap}{}{gap}{}", + pad("ID", id_w), + pad("ROOT", root_w), + pad("ENVS", envs_w), + "SYNC", + ); + out.push_str(&styles.paint(bold(), header.trim_end())); + out.push('\n'); + + for project in projects { + let id = pad(&render::truncate(&project.id, id_w), id_w); + let root = pad( + &render::truncate(&render::tilde(&project.root), root_w), + root_w, + ); + let envs = pad(&render::truncate(&envs_cell(project), envs_w), envs_w); + let sync = render::truncate(&sync_cell(project), sync_w); + // An unlinked project is a fact about the project, not a warning. + let sync = if project.sync.is_none() { + styles.paint(dim(), &sync) + } else { + sync + }; + + let line = format!("{id}{gap}{root}{gap}{envs}{gap}{sync}"); + out.push_str(line.trim_end()); + out.push('\n'); + } + out +} + +/// One environment's names and where each came from, under a line saying what +/// the environment is. `now` is injected so this is testable without a clock. +pub fn render_list( + project: &str, + env: &str, + vars: &[EnvVarInfo], + synced_at: Option>, + now: DateTime, + styles: &Styles, +) -> String { + let count = |want: EnvVarSource| vars.iter().filter(|v| v.source == want).count(); + let overrides = count(EnvVarSource::LocalOverride); + let synced = count(EnvVarSource::Synced) + overrides; + let local = count(EnvVarSource::Local) + overrides; + // A synced layer that has never been pulled is not "0 seconds old". + let when = match synced_at { + Some(at) => format!("synced {}", render::humanize_ago(now, at)), + None => "never pulled".to_string(), + }; + + let mut out = format!( + "{project}/{env} · {} variable{} · {synced} synced · {local} local · {overrides} \ + override{} · {when}\n", + vars.len(), + plural(vars.len()), + plural(overrides) + ); + if vars.is_empty() { + out.push_str(" nothing here yet — `pb env pull` fills the synced layer, `pb env set` the local one\n"); + return out; + } + + let name_w = column_width( + vars.iter().map(|v| v.name.chars().count()), + "NAME", + COL_NAME_MAX, + ); + let gap = " ".repeat(GAP); + let header = format!("{}{gap}{}", pad("NAME", name_w), "SOURCE"); + out.push_str(&styles.paint(bold(), header.trim_end())); + out.push('\n'); + + for var in vars { + let name = pad(&render::truncate(&var.name, name_w), name_w); + let label = pad(var.source.label(), COL_SOURCE); + // An override is the one row that changes what a consumer sees, so it + // is the one row worth colouring. + let source = match var.source { + EnvVarSource::LocalOverride => styles.paint(yellow(), &label), + EnvVarSource::Local => label, + EnvVarSource::Synced => styles.paint(dim(), &label), + }; + let line = format!("{name}{gap}{source}"); + out.push_str(line.trim_end()); + out.push('\n'); + } + out +} + +fn render_diff(project: &str, env: &str, diff: &Diff, styles: &Styles) -> String { + let mut out = format!("{project}/{env}\n"); + if diff.is_empty() { + out.push_str(" no variables in either layer\n"); + return out; + } + let mut section = |title: &str, names: &[String], style: anstyle::Style| { + if names.is_empty() { + return; + } + out.push_str(&styles.paint(style, &format!(" {title} ({})", names.len()))); + out.push('\n'); + for name in names { + out.push_str(&format!(" {name}\n")); + } + }; + section( + "local overrides — shadowing a synced value", + &diff.overrides, + yellow(), + ); + section( + "local only — never pushed anywhere", + &diff.local_only, + bold(), + ); + section( + "synced only — replaced by the next pull", + &diff.synced_only, + dim(), + ); + out +} + +fn bold() -> anstyle::Style { + anstyle::Style::new() | anstyle::Effects::BOLD +} + +fn dim() -> anstyle::Style { + anstyle::Style::new() | anstyle::Effects::DIMMED +} + +fn yellow() -> anstyle::Style { + anstyle::Style::new().fg_color(Some(anstyle::AnsiColor::Yellow.into())) +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::Duration; + use patchbay_core::keystore::MemoryKeystore; + + fn now() -> DateTime { + DateTime::parse_from_rfc3339("2026-01-01T00:00:00Z") + .unwrap() + .with_timezone(&Utc) + } + + /// A vault in a tempdir over a fake keystore. Nothing here touches the real + /// `$HOME`, the real keychain or a real process. + fn vault() -> (tempfile::TempDir, EnvRegistry) { + let dir = tempfile::tempdir().unwrap(); + let registry = EnvRegistry::new( + dir.path().join("projects.json"), + Box::new(MemoryKeystore::new()), + ); + (dir, registry) + } + + /// One project, one environment, both layers, one override. + fn seeded(registry: &EnvRegistry) { + registry + .register("pathors", "/repos/pathors", "dev") + .unwrap(); + registry + .replace_synced( + "pathors", + "dev", + [ + ("API_KEY".to_string(), "remote-key".to_string()), + ("DATABASE_URL".to_string(), "postgres://remote".to_string()), + ] + .into_iter() + .collect(), + now() - Duration::hours(2), + ) + .unwrap(); + registry + .set_local("pathors", "dev", "DATABASE_URL", "postgres://localhost") + .unwrap(); + registry + .set_local("pathors", "dev", "MY_FLAG", "true") + .unwrap(); + } + + fn var(name: &str, source: EnvVarSource) -> EnvVarInfo { + EnvVarInfo { + name: name.to_string(), + source, + } + } + + // --- tables ------------------------------------------------------------- + + #[test] + fn test_projects_table_is_plain_and_aligned_without_color() { + let (_dir, registry) = vault(); + seeded(®istry); + registry + .register("side", "/repos/side-project", "dev") + .unwrap(); + registry + .set_sync( + "pathors", + SyncConfig { + provider: "infisical".into(), + project_id: "3ab516bd".into(), + account: "contact@pathors.com".into(), + domain: None, + env_map: BTreeMap::new(), + }, + ) + .unwrap(); + + let projects = registry.projects().unwrap(); + let out = render_projects(&projects, &Styles::new(false)); + assert!(!out.contains('\u{1b}'), "plain mode must emit no ANSI"); + + let lines: Vec<&str> = out.lines().collect(); + assert!(lines[0].starts_with("ID"), "{out}"); + assert_eq!(lines.len(), 3, "{out}"); + assert!(lines[1].contains("/repos/pathors"), "{out}"); + assert!(lines[1].contains("dev"), "{out}"); + assert!(lines[1].contains("infisical:contact@pathors.com"), "{out}"); + // An unlinked project with no environments shows dashes, not blanks. + assert!(lines[2].contains(DASH), "{out}"); + + // Every column starts at the same offset on every row. + let col = lines[0].find("ROOT").unwrap(); + assert!(lines[1][col..].starts_with("/repos/pathors"), "{out}"); + assert!(lines[2][col..].starts_with("/repos/side-project"), "{out}"); + } + + #[test] + fn test_list_names_the_layers_and_never_shows_a_value() { + let (_dir, registry) = vault(); + seeded(®istry); + + let vars = registry.list("pathors", "dev").unwrap(); + let synced_at = registry.get("pathors").unwrap().unwrap().environments["dev"].synced_at; + let out = render_list( + "pathors", + "dev", + &vars, + synced_at, + now(), + &Styles::new(false), + ); + assert!(!out.contains('\u{1b}'), "plain mode must emit no ANSI"); + + let lines: Vec<&str> = out.lines().collect(); + // The header line says what this environment is before the table does. + assert!(lines[0].starts_with("pathors/dev · 3 variables"), "{out}"); + assert!(lines[0].contains("2 synced"), "{out}"); + assert!(lines[0].contains("2 local"), "{out}"); + assert!(lines[0].contains("1 override"), "{out}"); + assert!(lines[0].contains("synced 2h ago"), "{out}"); + assert!(lines[1].starts_with("NAME"), "{out}"); + + assert!(lines[2].starts_with("API_KEY"), "{out}"); + assert!(lines[2].ends_with("synced"), "{out}"); + assert!(lines[3].contains("local override"), "{out}"); + assert!(lines[4].contains("MY_FLAG"), "{out}"); + assert_eq!(lines.len(), 5, "{out}"); + + // Not one value from either layer reached the screen. + for value in [ + "postgres://localhost", + "postgres://remote", + "remote-key", + "true", + ] { + assert!(!out.contains(value), "`{value}` leaked into {out}"); + } + } + + #[test] + fn test_a_never_pulled_environment_says_so_rather_than_showing_an_age() { + let out = render_list( + "pathors", + "dev", + &[var("MY_FLAG", EnvVarSource::Local)], + None, + now(), + &Styles::new(false), + ); + assert!(out.contains("never pulled"), "{out}"); + assert!(out.contains("1 variable ·"), "{out}"); + assert!(!out.contains("ago"), "{out}"); + } + + #[test] + fn test_an_empty_environment_says_how_to_fill_it() { + let out = render_list("pathors", "dev", &[], None, now(), &Styles::new(false)); + assert!(out.contains("0 variables"), "{out}"); + assert!(out.contains("pb env pull"), "{out}"); + assert!(!out.contains("NAME"), "{out}"); + } + + #[test] + fn test_an_override_is_coloured_when_color_is_on() { + let out = render_list( + "pathors", + "dev", + &[var("DATABASE_URL", EnvVarSource::LocalOverride)], + None, + now(), + &Styles::new(true), + ); + assert!(out.contains('\u{1b}'), "{out}"); + assert!(out.contains("local override"), "{out}"); + } + + // --- diff --------------------------------------------------------------- + + #[test] + fn test_diff_buckets_by_source_and_prints_names_only() { + let (_dir, registry) = vault(); + seeded(®istry); + + let diff = Diff::of(®istry.list("pathors", "dev").unwrap()); + assert_eq!(diff.overrides, vec!["DATABASE_URL"]); + assert_eq!(diff.local_only, vec!["MY_FLAG"]); + assert_eq!(diff.synced_only, vec!["API_KEY"]); + + let out = render_diff("pathors", "dev", &diff, &Styles::new(false)); + assert!(out.starts_with("pathors/dev\n"), "{out}"); + assert!(out.contains("local overrides"), "{out}"); + assert!(out.contains("local only"), "{out}"); + assert!(out.contains("synced only"), "{out}"); + assert!(out.contains(" DATABASE_URL\n"), "{out}"); + assert!(!out.contains("postgres"), "{out}"); + + // The sections are in order: what shadows, what is only here, what is + // only there. + let at = |needle: &str| out.find(needle).unwrap(); + assert!(at("local overrides") < at("local only"), "{out}"); + assert!(at("local only") < at("synced only"), "{out}"); + } + + #[test] + fn test_diff_skips_empty_sections_and_says_when_there_is_nothing() { + let diff = Diff::of(&[var("MY_FLAG", EnvVarSource::Local)]); + let out = render_diff("pathors", "dev", &diff, &Styles::new(false)); + assert!(out.contains("local only"), "{out}"); + assert!(out.contains("(1)"), "{out}"); + assert!(!out.contains("synced only"), "{out}"); + assert!(!out.contains("local overrides"), "{out}"); + + let out = render_diff("pathors", "dev", &Diff::default(), &Styles::new(false)); + assert!(out.contains("no variables in either layer"), "{out}"); + } + + // --- export ------------------------------------------------------------- + + #[test] + fn test_export_renders_dotenv_and_json_from_the_same_merged_values() { + let (_dir, registry) = vault(); + seeded(®istry); + let merged = registry.merged("pathors", "dev").unwrap(); + + // The local layer wins, which is the whole reason it exists. + let dotenv = render_dotenv(&merged.vars); + assert_eq!( + dotenv, + "API_KEY='remote-key'\nDATABASE_URL='postgres://localhost'\nMY_FLAG='true'\n" + ); + + let json = render_json(&merged.vars).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed["DATABASE_URL"], "postgres://localhost"); + assert_eq!(parsed["MY_FLAG"], "true"); + assert_eq!(parsed.as_object().unwrap().len(), 3); + // A flat object of strings — no layer metadata smuggled alongside. + assert!(parsed.as_object().unwrap().values().all(|v| v.is_string())); + } + + #[test] + fn test_export_json_quotes_what_dotenv_would_have_to_escape() { + let vars: BTreeMap = [ + ("A".to_string(), "line\nbreak".to_string()), + ("B".to_string(), "it's".to_string()), + ] + .into_iter() + .collect(); + + assert_eq!( + render_dotenv(&vars), + "A=\"line\\nbreak\"\nB='it'\\''s'\n", + "the core's dotenv quoting is what keeps this one line per variable" + ); + let parsed: serde_json::Value = serde_json::from_str(&render_json(&vars).unwrap()).unwrap(); + assert_eq!(parsed["A"], "line\nbreak"); + assert_eq!(parsed["B"], "it's"); + } + + // --- run ---------------------------------------------------------------- + + #[test] + fn test_the_child_environment_puts_the_vault_over_what_was_inherited() { + let inherited: Vec<(OsString, OsString)> = [ + ("PATH", "/usr/bin"), + ("DATABASE_URL", "postgres://stale-shell-export"), + ] + .into_iter() + .map(|(k, v)| (OsString::from(k), OsString::from(v))) + .collect(); + let merged: BTreeMap = [ + ( + "DATABASE_URL".to_string(), + "postgres://localhost".to_string(), + ), + ("MY_FLAG".to_string(), "true".to_string()), + ] + .into_iter() + .collect(); + + let env = child_env(inherited, &merged); + let lookup = |name: &str| { + env.iter() + .filter(|(k, _)| k == name) + .map(|(_, v)| v.to_string_lossy().to_string()) + .collect::>() + }; + + // The vault wins, and it wins exactly once: a duplicated name would let + // the child's own lookup decide which value it got. + assert_eq!(lookup("DATABASE_URL"), vec!["postgres://localhost"]); + assert_eq!(lookup("MY_FLAG"), vec!["true"]); + // Everything else is inherited untouched. + assert_eq!(lookup("PATH"), vec!["/usr/bin"]); + assert_eq!(env.len(), 3); + } + + // --- init -------------------------------------------------------------- + + #[test] + fn test_the_default_id_is_the_directory_name_as_a_slug() { + let id = |path: &str| default_project_id(Path::new(path)); + assert_eq!(id("/repos/pathors").unwrap(), "pathors"); + assert_eq!(id("/repos/Patchbay").unwrap(), "patchbay"); + assert_eq!(id("/repos/my repo (old)").unwrap(), "my-repo--old-"); + assert_eq!(id("/repos/app.v2_final").unwrap(), "app.v2_final"); + // Non-ASCII is not a slug character, so it folds like anything else. + assert_eq!(id("/repos/app專案").unwrap(), "app--"); + } + + #[test] + fn test_a_name_that_cannot_be_a_slug_asks_for_one() { + // A leading dot survives slugification but is not a legal id start. + let err = default_project_id(Path::new("/repos/.hidden")) + .unwrap_err() + .to_string(); + assert!(err.contains("must start with a letter or digit"), "{err}"); + assert!(err.contains("--id"), "{err}"); + + // Same for a name whose *first* character is one patchbay had to fold. + let err = default_project_id(Path::new("/repos/專案")) + .unwrap_err() + .to_string(); + assert!(err.contains("must start with a letter or digit"), "{err}"); + + let err = default_project_id(Path::new("/")).unwrap_err().to_string(); + assert!(err.contains("no directory name"), "{err}"); + } + + #[test] + fn test_env_map_pairs_parse_and_a_malformed_one_is_named() { + let pairs = + |raw: &[&str]| parse_env_map(&raw.iter().map(|s| s.to_string()).collect::>()); + + let map = pairs(&["production=prod", " dev = development "]).unwrap(); + assert_eq!(map["production"], "prod"); + assert_eq!(map["dev"], "development"); + assert_eq!(map.len(), 2); + // A trailing comma is a typo, not an instruction. + assert_eq!(pairs(&["production=prod", ""]).unwrap().len(), 1); + assert!(pairs(&[]).unwrap().is_empty()); + + let err = pairs(&["production"]).unwrap_err().to_string(); + assert!(err.contains("is not `local=remote`"), "{err}"); + let err = pairs(&["=prod"]).unwrap_err().to_string(); + assert!(err.contains("empty side"), "{err}"); + } + + #[test] + fn test_infisical_json_is_read_tolerantly() { + let file = parse_infisical_json( + r#"{"workspaceId":"3ab516bd-248c","defaultEnvironment":"prod", + "gitBranchToEnvironmentMapping":null,"somethingNew":42}"#, + ) + .unwrap(); + assert_eq!(file.workspace_id.as_deref(), Some("3ab516bd-248c")); + assert_eq!(file.default_environment.as_deref(), Some("prod")); + + // The CLI writes an empty default environment far more often than a + // useful one, and empty is not a name. + let file = + parse_infisical_json(r#"{"workspaceId":"abc","defaultEnvironment":""}"#).unwrap(); + assert_eq!(file.default_environment, None); + assert_eq!( + parse_infisical_json("{}").unwrap(), + InfisicalProject::default() + ); + + // Malformed is an error the caller turns into a note, not a panic. + let err = parse_infisical_json("{not json").unwrap_err().to_string(); + assert!(err.contains("not valid JSON"), "{err}"); + let err = parse_infisical_json("[]").unwrap_err().to_string(); + assert!(err.contains("not a JSON object"), "{err}"); + } + + #[test] + fn test_forgetting_a_project_takes_every_stored_layer_with_it() { + let (_dir, registry) = vault(); + seeded(®istry); + assert!(registry + .get("pathors") + .unwrap() + .unwrap() + .env("dev") + .is_some()); + + let removed = registry.forget("pathors").unwrap(); + assert_eq!(removed.id, "pathors"); + assert!(registry.projects().unwrap().is_empty()); + // And the reason `pb env forget` says nothing was revoked: the keychain + // items are gone, the remote's copies are not ours to touch. + assert!(registry.get("pathors").unwrap().is_none()); + } +} diff --git a/crates/patchbay-cli/src/main.rs b/crates/patchbay-cli/src/main.rs index fd54ace..9ab0ca0 100644 --- a/crates/patchbay-cli/src/main.rs +++ b/crates/patchbay-cli/src/main.rs @@ -4,6 +4,7 @@ //! probes found, and never reads tool state itself. Formatting lives in //! [`render`]; this file is argument parsing, dispatch and exit codes. +mod env; mod keys; mod mcp; mod migrate; @@ -88,6 +89,11 @@ enum Command { #[command(subcommand)] command: keys::Command, }, + /// Project env vault: per-project variables in synced + local layers. + Env { + #[command(subcommand)] + command: env::Command, + }, /// MCP servers across the AI clients on this machine. Mcp { #[command(subcommand)] @@ -241,6 +247,10 @@ fn run() -> Result { // The vault has its own registry: it stores keys the user gave patchbay // on purpose, not state discovered by a probe. Command::Key { command } => keys::run(command, &styles()), + // The env vault is the other half of the same idea: values the user + // handed patchbay on purpose, filed against a directory instead of a + // person. + Command::Env { command } => env::run(command, &styles()), // Likewise the MCP board: these are other tools' config files, not // credential state, so it has its own registry too. Command::Mcp { command } => mcp::run(command, &styles()), diff --git a/crates/patchbay-cli/src/mcp.rs b/crates/patchbay-cli/src/mcp.rs index 89edf99..a24ba41 100644 --- a/crates/patchbay-cli/src/mcp.rs +++ b/crates/patchbay-cli/src/mcp.rs @@ -9,7 +9,6 @@ //! those fields hold `--figma-api-key=…` and `Authorization: Bearer …`. use std::io::Write; -use std::path::Path; use anyhow::{Context, Result}; use clap::{Args, Subcommand}; @@ -250,9 +249,9 @@ fn confirm(client: &McpClient, entry: &McpServerEntry) -> Result { // --------------------------------------------------------------------------- fn print_write(report: &WriteReport) { - println!(" config: {}", tilde(&report.config_path)); + println!(" config: {}", render::tilde(&report.config_path)); match &report.backup_path { - Some(path) => println!(" backup: {}", tilde(path)), + Some(path) => println!(" backup: {}", render::tilde(path)), None => println!(" backup: none — the file did not exist yet"), } for note in &report.notes { @@ -280,9 +279,9 @@ fn print_copy(report: &CopyReport) { } for write in &report.written { println!(" {}:", write.label); - println!(" config: {}", tilde(&write.config_path)); + println!(" config: {}", render::tilde(&write.config_path)); if let Some(path) = &write.backup_path { - println!(" backup: {}", tilde(path)); + println!(" backup: {}", render::tilde(path)); } for note in &write.notes { println!(" note: {note}"); @@ -290,18 +289,6 @@ fn print_copy(report: &CopyReport) { } } -/// `~/…` for paths under the home directory: the table is about which file, not -/// about how long the user's home path is. -fn tilde(path: &Path) -> String { - let Some(home) = std::env::var_os("HOME") else { - return path.display().to_string(); - }; - match path.strip_prefix(&home) { - Ok(rest) => format!("~/{}", rest.display()), - Err(_) => path.display().to_string(), - } -} - fn pad(s: &str, width: usize) -> String { let len = s.chars().count(); if len >= width { @@ -347,7 +334,7 @@ pub fn render_board(clients: &[McpClient], styles: &Styles) -> String { out.push_str(&format!( " {} would keep one at {}\n", client.label, - tilde(&client.config_path) + render::tilde(&client.config_path) )); } return out; @@ -397,7 +384,7 @@ pub fn render_board(clients: &[McpClient], styles: &Styles) -> String { out.push('\n'); out.push_str(&styles.paint(bold(), &format!("{} — {}", client.client, client.label))); out.push('\n'); - out.push_str(&format!(" {}\n", tilde(&client.config_path))); + out.push_str(&format!(" {}\n", render::tilde(&client.config_path))); if client.servers.is_empty() { out.push_str(&format!(" {}\n", styles.paint(dim(), "no servers"))); } @@ -435,7 +422,7 @@ pub fn render_board(clients: &[McpClient], styles: &Styles) -> String { out.push_str(&format!( " {}: {}\n", client.client, - tilde(&client.config_path) + render::tilde(&client.config_path) )); } } diff --git a/crates/patchbay-cli/src/render.rs b/crates/patchbay-cli/src/render.rs index e01fdd1..5454fae 100644 --- a/crates/patchbay-cli/src/render.rs +++ b/crates/patchbay-cli/src/render.rs @@ -143,6 +143,18 @@ pub fn humanize_expiry(now: DateTime, at: DateTime) -> String { } } +/// How long ago something happened: `2h ago`, `3d 4h ago`. +/// +/// A timestamp in the future is a clock that disagrees with itself, not news +/// worth reporting, so it reads as `just now` rather than a negative age. +pub fn humanize_ago(now: DateTime, at: DateTime) -> String { + let elapsed = now - at; + if elapsed <= Duration::zero() { + return "just now".to_string(); + } + format!("{} ago", magnitude(elapsed)) +} + // --------------------------------------------------------------------------- // truncation / padding // --------------------------------------------------------------------------- @@ -537,6 +549,18 @@ pub fn render_check_updates( // shared bits for the other subcommands // --------------------------------------------------------------------------- +/// `~/…` for paths under the home directory: output is about *which* file, not +/// about how long the user's home path is. +pub fn tilde(path: &std::path::Path) -> String { + let Some(home) = std::env::var_os("HOME") else { + return path.display().to_string(); + }; + match path.strip_prefix(&home) { + Ok(rest) => format!("~/{}", rest.display()), + Err(_) => path.display().to_string(), + } +} + /// Indent a list of lines under a heading line. pub fn indent_lines(items: &[String]) -> String { items @@ -600,6 +624,17 @@ mod tests { ); } + #[test] + fn test_humanize_ago_reads_as_an_age() { + assert_eq!(humanize_ago(now(), now() - Duration::hours(2)), "2h ago"); + assert_eq!( + humanize_ago(now(), now() - Duration::days(3) - Duration::hours(4)), + "3d 4h ago" + ); + // A future timestamp is a disagreeing clock, not a negative age. + assert_eq!(humanize_ago(now(), now() + Duration::hours(1)), "just now"); + } + #[test] fn test_humanize_past() { assert_eq!( diff --git a/crates/patchbay-core/src/env_sync.rs b/crates/patchbay-core/src/env_sync.rs new file mode 100644 index 0000000..9985842 --- /dev/null +++ b/crates/patchbay-core/src/env_sync.rs @@ -0,0 +1,619 @@ +//! Pulling a project's synced layer from Infisical. +//! +//! One direction only. This module reads the remote and replaces the synced +//! layer of one environment ([`crate::envs::EnvRegistry::replace_synced`]); it +//! has no push, and adding one would break the promise the local layer rests +//! on. Whatever is in `local` stays on this machine. +//! +//! **The account guard is the reason this module is more than four lines.** The +//! `infisical` CLI's active user is machine-global — one field in +//! `~/.infisical/infisical-config.json`, shared by every shell, every project +//! and every agent on the box. An `infisical export` therefore runs as whoever +//! logged in last, not as whoever the project belongs to, and when those differ +//! the API answers with a 403 whose text is actively misleading: *"project does +//! not belong to your selected organization"*, which reads as a permissions +//! problem with the project rather than the wrong login. So the pull records +//! the account it expects, checks it *before* spending a subprocess, and when +//! they disagree it says both addresses and the command that fixes it. + +use std::collections::BTreeMap; + +use chrono::Utc; +use serde::{Deserialize, Serialize}; + +use crate::envs::{validate_var_name, EnvRegistry, EnvVarSource, ProjectEntry}; +use crate::paths::Paths; +use crate::probes::infisical; + +/// The wrong-organization 403 the API returns when the active login is not the +/// one the project belongs to. +const WRONG_ORG: &str = "does not belong to your selected organization"; + +/// What a pull did. Names and counts only — no values, so this is safe to +/// serialize into an MCP response or a `--json` CLI output. +#[derive(Debug, Clone, Serialize)] +pub struct PullOutcome { + /// The patchbay environment that was replaced. + pub env: String, + /// The remote's slug for it, which is not always the same thing. + pub remote_env: String, + /// How many variables the synced layer now holds. + pub count: usize, + /// Local names that shadow a synced one, after this pull. + pub overridden: Vec, + /// Anything the user should know: skipped names, duplicates, overrides. + pub notes: Vec, +} + +/// One secret as `infisical export --format json` reports it. +/// +/// Verified against infisical CLI 0.43: a JSON array of objects with `key` and +/// `value` string fields, plus `_id`, `workspace`, `type`, `tags` and others +/// that change between releases and are deliberately ignored here. +#[derive(Deserialize)] +struct RemoteSecret { + key: String, + value: String, +} + +/// Replace one environment's synced layer with what the remote holds. +pub fn pull( + paths: &Paths, + registry: &EnvRegistry, + project: &ProjectEntry, + env: &str, +) -> anyhow::Result { + let Some(sync) = &project.sync else { + anyhow::bail!( + "no sync configured for `{}`; link it with `pb env link --project-id `", + project.id + ); + }; + if sync.provider != "infisical" { + anyhow::bail!( + "`{}` is linked to `{}`, which patchbay cannot pull from; the only provider today is \ + `infisical`", + project.id, + sync.provider + ); + } + + // Before the subprocess, not after: running as the wrong user costs a + // network round trip and answers with a lie (see the module docs). + match infisical::active_account(paths)? { + None => anyhow::bail!( + "no infisical login on this machine; run `infisical login`, then `pb use infisical {}`", + sync.account + ), + Some(active) if active != sync.account => anyhow::bail!( + "`{}` is linked to the infisical account `{}`, but `{active}` is the active login on \ + this machine; the infisical CLI has one active user for the whole machine, so switch \ + first: `pb use infisical {}`", + project.id, + sync.account, + sync.account + ), + Some(_) => {} + } + + if !paths.may_exec() || !paths.has_binary("infisical") { + anyhow::bail!( + "the infisical CLI is not available on PATH; install it, or export the values by hand \ + and import them with `pb env import`" + ); + } + + let remote_env = sync.remote_env(env); + let mut args: Vec = vec![ + "export".into(), + "--projectId".into(), + sync.project_id.clone(), + "--env".into(), + remote_env.clone(), + "--format".into(), + "json".into(), + // Without it the CLI decorates stdout with its own banner, and stdout + // has to stay parseable JSON. + "--silent".into(), + ]; + if let Some(domain) = &sync.domain { + args.push("--domain".into()); + args.push(domain.clone()); + } + let argv: Vec<&str> = args.iter().map(String::as_str).collect(); + let out = paths.run_env("infisical", &argv, &[])?; + + if !out.ok { + // stderr **only**. On some failure modes — a partial export, a broken + // pipe — stdout can already hold secret material, and an error message + // is the one string guaranteed to be logged, printed and pasted. + let mut detail = first_lines(&out.stderr); + if out.stderr.contains(WRONG_ORG) { + detail.push_str(&format!( + " — that 403 usually means the wrong login: this project belongs to `{}`, so run \ + `pb use infisical {}` and try again", + sync.account, sync.account + )); + } + anyhow::bail!( + "`infisical export` failed for `{}/{env}` (remote environment `{remote_env}`): {detail}", + project.id + ); + } + + let secrets: Vec = serde_json::from_str(&out.stdout).map_err(|e| { + anyhow::anyhow!( + "unexpected `infisical export` output for `{}/{env}` ({e}); patchbay expects the JSON \ + array that `infisical export --format json` produces", + project.id + ) + })?; + + let mut notes = Vec::new(); + let mut vars: BTreeMap = BTreeMap::new(); + let mut duplicated: Vec = Vec::new(); + for secret in secrets { + // A name the shell could not export is skipped, not fatal: one odd key + // in a shared project must not stop everyone else's pull. + if let Err(e) = validate_var_name(&secret.key) { + notes.push(format!("skipped a remote name: {e}")); + continue; + } + if vars.insert(secret.key.clone(), secret.value).is_some() + && !duplicated.contains(&secret.key) + { + duplicated.push(secret.key); + } + } + if !duplicated.is_empty() { + notes.push(format!( + "the remote returned {} more than once; the last value won", + duplicated + .iter() + .map(|k| format!("`{k}`")) + .collect::>() + .join(", ") + )); + } + + let count = vars.len(); + registry.replace_synced(&project.id, env, vars, Utc::now())?; + + let overridden: Vec = registry + .list(&project.id, env)? + .into_iter() + .filter(|var| var.source == EnvVarSource::LocalOverride) + .map(|var| var.name) + .collect(); + if !overridden.is_empty() { + notes.push(format!( + "{} local override{} synced values: {} — `pb env diff` shows them", + overridden.len(), + if overridden.len() == 1 { + " shadows" + } else { + "s shadow" + }, + overridden.join(", ") + )); + } + + Ok(PullOutcome { + env: env.to_string(), + remote_env, + count, + overridden, + notes, + }) +} + +/// stderr condensed to one line for an error message. Blank lines dropped, the +/// rest joined — the infisical CLI spreads a single failure over several. +fn first_lines(stderr: &str) -> String { + let text: Vec<&str> = stderr + .lines() + .map(str::trim) + .filter(|l| !l.is_empty()) + .collect(); + if text.is_empty() { + return "no output".to_string(); + } + text.join("; ") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::envs::{keychain_account, EnvLayer, SyncConfig}; + use crate::keystore::{Keystore, MemoryKeystore}; + use crate::util::FakeExec; + use std::sync::Arc; + + /// The shape `infisical export --format json` really produces, extra fields + /// and all. + const EXPORT: &str = r#"[ + {"_id":"6a1","workspace":"3ab516bd","environment":"dev","type":"shared","tags":[], + "key":"DATABASE_URL","value":"postgres://remote/db"}, + {"_id":"6a2","workspace":"3ab516bd","environment":"dev","type":"shared","tags":[], + "key":"API_KEY","value":"remote-key"} + ]"#; + + /// A tempdir home with an infisical login, a tempdir registry with a linked + /// project, and a scripted exec. Nothing real is touched. + struct Rig { + _home: tempfile::TempDir, + _dir: tempfile::TempDir, + paths: Paths, + registry: EnvRegistry, + exec: Arc, + store: Arc, + } + + struct Shared(Arc); + + impl Keystore for Shared { + fn put(&self, id: &str, secret: &str) -> anyhow::Result<()> { + self.0.put(id, secret) + } + fn get(&self, id: &str) -> anyhow::Result> { + self.0.get(id) + } + fn delete(&self, id: &str) -> anyhow::Result { + self.0.delete(id) + } + fn describe(&self) -> &'static str { + self.0.describe() + } + } + + /// `active` is the machine-global infisical login; `None` writes no config + /// file at all, which is what "never logged in" looks like. + fn rig(active: Option<&str>, exec: FakeExec) -> Rig { + let home = tempfile::tempdir().unwrap(); + if let Some(active) = active { + std::fs::create_dir_all(home.path().join(".infisical")).unwrap(); + std::fs::write( + home.path().join(".infisical/infisical-config.json"), + format!( + r#"{{"loggedInUserEmail":"{active}","LoggedInUserDomain":"https://app.infisical.com/api","loggedInUsers":[{{"email":"{active}","domain":"https://app.infisical.com/api"}}],"vaultBackendType":"file","vaultBackendPassphrase":"ZmFrZQ=="}}"# + ), + ) + .unwrap(); + } + let exec = Arc::new(exec); + let paths = Paths::for_test(home.path()).with_exec(exec.clone()); + + let dir = tempfile::tempdir().unwrap(); + let store = Arc::new(MemoryKeystore::new()); + let registry = EnvRegistry::new( + dir.path().join("projects.json"), + Box::new(Shared(store.clone())), + ); + registry + .register("pathors", "/repos/pathors", "dev") + .unwrap(); + + Rig { + _home: home, + _dir: dir, + paths, + registry, + exec, + store, + } + } + + impl Rig { + fn link(&self, sync: SyncConfig) -> ProjectEntry { + self.registry.set_sync("pathors", sync).unwrap() + } + + fn synced_blob(&self, env: &str) -> BTreeMap { + let raw = self + .store + .get(&keychain_account("pathors", env, EnvLayer::Synced)) + .unwrap() + .expect("no synced item"); + serde_json::from_str(&raw).unwrap() + } + } + + fn sync_for(account: &str) -> SyncConfig { + SyncConfig { + provider: "infisical".into(), + project_id: "3ab516bd-248c-4be7-8f1a-bda73fe69d50".into(), + account: account.into(), + domain: None, + env_map: BTreeMap::new(), + } + } + + #[test] + fn test_a_pull_replaces_the_synced_layer_and_runs_the_expected_command() { + let rig = rig( + Some("contact@pathors.com"), + FakeExec::new().on("export", true, EXPORT, "exported 2 secrets\n"), + ); + let project = rig.link(sync_for("contact@pathors.com")); + + let outcome = pull(&rig.paths, &rig.registry, &project, "dev").unwrap(); + assert_eq!(outcome.env, "dev"); + assert_eq!(outcome.remote_env, "dev"); + assert_eq!(outcome.count, 2); + assert!(outcome.overridden.is_empty()); + assert!(outcome.notes.is_empty(), "{:?}", outcome.notes); + + let call = rig.exec.last().unwrap(); + assert_eq!(call.bin, "infisical"); + assert_eq!( + call.args, + vec![ + "export", + "--projectId", + "3ab516bd-248c-4be7-8f1a-bda73fe69d50", + "--env", + "dev", + "--format", + "json", + "--silent", + ] + ); + + assert_eq!( + rig.synced_blob("dev"), + [ + ("API_KEY".to_string(), "remote-key".to_string()), + ( + "DATABASE_URL".to_string(), + "postgres://remote/db".to_string() + ), + ] + .into_iter() + .collect() + ); + // Names on disk, values not. + let raw = std::fs::read_to_string(rig.registry.path()).unwrap(); + assert!(raw.contains("DATABASE_URL"), "{raw}"); + assert!(!raw.contains("postgres://remote/db"), "{raw}"); + assert!(!raw.contains("remote-key"), "{raw}"); + + // The outcome is safe to serialize: no values in it either. + let json = serde_json::to_string(&outcome).unwrap(); + assert!(!json.contains("remote-key"), "{json}"); + } + + #[test] + fn test_the_env_map_and_domain_reach_the_command_line() { + let rig = rig( + Some("contact@pathors.com"), + FakeExec::new().on("export", true, EXPORT, ""), + ); + let mut sync = sync_for("contact@pathors.com"); + sync.domain = Some("https://eu.infisical.com/api".into()); + sync.env_map = [("production".to_string(), "prod".to_string())] + .into_iter() + .collect(); + let project = rig.link(sync); + + let outcome = pull(&rig.paths, &rig.registry, &project, "production").unwrap(); + assert_eq!(outcome.env, "production"); + assert_eq!(outcome.remote_env, "prod"); + + let line = rig.exec.last().unwrap().line(); + assert!(line.contains("--env prod"), "{line}"); + assert!( + line.contains("--domain https://eu.infisical.com/api"), + "{line}" + ); + // patchbay's own name for the environment is what the vault records. + assert!(rig + .registry + .get("pathors") + .unwrap() + .unwrap() + .env("production") + .is_some()); + } + + #[test] + fn test_the_wrong_active_account_refuses_before_spending_a_subprocess() { + let rig = rig( + Some("someone.else@example.com"), + FakeExec::new().on("export", true, EXPORT, ""), + ); + let project = rig.link(sync_for("contact@pathors.com")); + + let err = pull(&rig.paths, &rig.registry, &project, "dev") + .unwrap_err() + .to_string(); + assert!(err.contains("contact@pathors.com"), "{err}"); + assert!(err.contains("someone.else@example.com"), "{err}"); + assert!( + err.contains("pb use infisical contact@pathors.com"), + "{err}" + ); + + // The guard is the point: nothing ran, and nothing was stored. + assert!(rig.exec.calls().is_empty(), "{:?}", rig.exec.calls()); + assert!(rig.store.is_empty()); + } + + #[test] + fn test_no_login_at_all_says_how_to_get_one() { + let rig = rig(None, FakeExec::new().on("export", true, EXPORT, "")); + let project = rig.link(sync_for("contact@pathors.com")); + + let err = pull(&rig.paths, &rig.registry, &project, "dev") + .unwrap_err() + .to_string(); + assert!(err.contains("no infisical login on this machine"), "{err}"); + assert!(err.contains("infisical login"), "{err}"); + assert!(rig.exec.calls().is_empty()); + } + + #[test] + fn test_an_unlinked_project_names_the_command_that_links_it() { + let rig = rig( + Some("contact@pathors.com"), + FakeExec::new().on("export", true, EXPORT, ""), + ); + let project = rig.registry.get("pathors").unwrap().unwrap(); + + let err = pull(&rig.paths, &rig.registry, &project, "dev") + .unwrap_err() + .to_string(); + assert!(err.contains("no sync configured for `pathors`"), "{err}"); + assert!(err.contains("pb env link"), "{err}"); + } + + #[test] + fn test_without_the_cli_the_pull_says_so_instead_of_failing_obscurely() { + // No scripted exec at all: `Paths::for_test` reports no binaries and + // refuses to execute, exactly like a machine without infisical. + let home = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(home.path().join(".infisical")).unwrap(); + std::fs::write( + home.path().join(".infisical/infisical-config.json"), + r#"{"loggedInUserEmail":"contact@pathors.com"}"#, + ) + .unwrap(); + let paths = Paths::for_test(home.path()); + + let dir = tempfile::tempdir().unwrap(); + let registry = EnvRegistry::new( + dir.path().join("projects.json"), + Box::new(MemoryKeystore::new()), + ); + registry + .register("pathors", "/repos/pathors", "dev") + .unwrap(); + let project = registry + .set_sync("pathors", sync_for("contact@pathors.com")) + .unwrap(); + + let err = pull(&paths, ®istry, &project, "dev") + .unwrap_err() + .to_string(); + assert!(err.contains("not available on PATH"), "{err}"); + assert!(err.contains("pb env import"), "{err}"); + } + + #[test] + fn test_a_403_from_the_wrong_organization_gets_the_account_hint() { + let rig = rig( + Some("contact@pathors.com"), + FakeExec::new().on( + "export", + false, + // stdout on a failed export is never shown; if it were, this + // would be the leak. + "partial-secret-material", + "error: CallGetSecretsV3: Unsuccessful response [403]\nproject does not belong to \ + your selected organization\n", + ), + ); + let project = rig.link(sync_for("contact@pathors.com")); + + let err = pull(&rig.paths, &rig.registry, &project, "dev") + .unwrap_err() + .to_string(); + assert!(err.contains("403"), "{err}"); + assert!(err.contains("usually means the wrong login"), "{err}"); + assert!( + err.contains("pb use infisical contact@pathors.com"), + "{err}" + ); + assert!(!err.contains("partial-secret-material"), "{err}"); + // A failed pull replaces nothing. + assert!(rig.store.is_empty()); + } + + #[test] + fn test_output_that_is_not_the_documented_shape_is_an_error() { + let rig = rig( + Some("contact@pathors.com"), + FakeExec::new().on("export", true, "not json at all", ""), + ); + let project = rig.link(sync_for("contact@pathors.com")); + + let err = pull(&rig.paths, &rig.registry, &project, "dev") + .unwrap_err() + .to_string(); + assert!( + err.contains("unexpected `infisical export` output"), + "{err}" + ); + assert!(rig.store.is_empty()); + } + + #[test] + fn test_duplicates_and_unusable_names_are_noted_not_fatal() { + let rig = rig( + Some("contact@pathors.com"), + FakeExec::new().on( + "export", + true, + r#"[{"key":"A","value":"first"}, + {"key":"A","value":"last"}, + {"key":"not a name","value":"x"}, + {"key":"B","value":"ok"}]"#, + "", + ), + ); + let project = rig.link(sync_for("contact@pathors.com")); + + let outcome = pull(&rig.paths, &rig.registry, &project, "dev").unwrap(); + assert_eq!(outcome.count, 2); + assert_eq!(rig.synced_blob("dev")["A"], "last"); + assert!( + outcome + .notes + .iter() + .any(|n| n.contains("`A`") && n.contains("last value won")), + "{:?}", + outcome.notes + ); + assert!( + outcome + .notes + .iter() + .any(|n| n.contains("skipped a remote name")), + "{:?}", + outcome.notes + ); + } + + #[test] + fn test_local_overrides_survive_a_pull_and_are_reported() { + let rig = rig( + Some("contact@pathors.com"), + FakeExec::new().on("export", true, EXPORT, ""), + ); + let project = rig.link(sync_for("contact@pathors.com")); + rig.registry + .set_local("pathors", "dev", "DATABASE_URL", "postgres://localhost") + .unwrap(); + + let outcome = pull(&rig.paths, &rig.registry, &project, "dev").unwrap(); + assert_eq!(outcome.overridden, vec!["DATABASE_URL"]); + assert!( + outcome + .notes + .iter() + .any(|n| n.contains("1 local override shadows") && n.contains("pb env diff")), + "{:?}", + outcome.notes + ); + + // The pull took the remote value into the synced layer and left the + // local one exactly where it was — which is what makes it in effect. + assert_eq!( + rig.synced_blob("dev")["DATABASE_URL"], + "postgres://remote/db" + ); + let merged = rig.registry.merged("pathors", "dev").unwrap(); + assert_eq!(merged.vars["DATABASE_URL"], "postgres://localhost"); + assert_eq!(merged.vars["API_KEY"], "remote-key"); + } +} diff --git a/crates/patchbay-core/src/envs.rs b/crates/patchbay-core/src/envs.rs new file mode 100644 index 0000000..2b17949 --- /dev/null +++ b/crates/patchbay-core/src/envs.rs @@ -0,0 +1,1880 @@ +//! The project env vault: the environment variables a *directory* needs. +//! +//! [`crate::keys`] holds standalone credentials that belong to a person or a +//! machine. This module holds the other half of the same problem: the twenty +//! variables a repo needs before it will boot — `DATABASE_URL`, the provider +//! keys, the feature flags — which today live in a `.env` file that is +//! gitignored, undocumented, and different on every laptop. +//! +//! **Two layers per environment**, and the split is the whole point: +//! +//! * `synced` — what the last pull took from the remote (Infisical). Replaced +//! wholesale by the next pull, never hand-edited. +//! * `local` — what this machine sets by hand. Never pushed anywhere, never +//! touched by a pull, and it *wins* on merge. These are `.env.local` +//! semantics: pointing `DATABASE_URL` at a container on your own machine has +//! to survive every `pull`, or nobody will trust `pull`. +//! +//! patchbay **never pushes**. There is no code path in this crate that writes a +//! variable to a remote secret manager, deliberately: a tool that can silently +//! promote a local experiment into the team's shared `production` set is a tool +//! nobody should run. +//! +//! **The storage split** mirrors the key vault. Variable *names* and where they +//! came from live in `~/.config/patchbay/projects.json` — readable, greppable, +//! worthless to an attacker. *Values* live in the OS keychain behind +//! [`Keystore`], one item per (project, environment, layer), holding a compact +//! JSON object of the whole layer. One keychain round trip per export rather +//! than one per variable, which is what makes `pb env export` fast enough to +//! put in a shell hook. +//! +//! No `last4` is recorded for an env var, unlike a key: half of these values +//! are `true`, `5432` or `postgres`, and four characters of a five-character +//! value is not a hint, it is the value. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +use crate::keys::validate_slug; +use crate::keystore::{Keystore, SecurityCliKeystore}; +use crate::paths::Paths; + +/// Schema version of `projects.json`. Bump on an incompatible change. +pub const PROJECTS_FILE_VERSION: u32 = 1; + +/// The environment a project uses when nobody says which. +pub const DEFAULT_ENV: &str = "dev"; + +// --------------------------------------------------------------------------- +// validation +// --------------------------------------------------------------------------- + +/// Project ids are lowercase slugs, exactly like key ids — they end up inside a +/// keychain account string, and they are what a human types on the CLI. +pub fn validate_project_id(id: &str) -> anyhow::Result<()> { + validate_slug("project id", id) +} + +/// Environment names follow the same rules: `dev`, `staging`, `production`. +/// The remote's own spelling can differ — that is what +/// [`SyncConfig::env_map`] is for. +pub fn validate_env_name(env: &str) -> anyhow::Result<()> { + validate_slug("environment name", env) +} + +/// `[A-Za-z_][A-Za-z0-9_]*` — what a POSIX shell will actually export. A name +/// outside this set cannot be set by `export` at all, so accepting it would +/// mean storing something no consumer of the vault could ever use. +pub fn validate_var_name(name: &str) -> anyhow::Result<()> { + let Some(first) = name.chars().next() else { + anyhow::bail!("an environment variable name cannot be empty"); + }; + if !(first.is_ascii_alphabetic() || first == '_') { + anyhow::bail!( + "`{name}` is not a usable environment variable name: it must start with a letter \ + or `_`" + ); + } + if let Some(bad) = name + .chars() + .find(|c| !(c.is_ascii_alphanumeric() || *c == '_')) + { + anyhow::bail!( + "environment variable name `{name}` contains `{bad}`; use letters, digits and `_`" + ); + } + Ok(()) +} + +// --------------------------------------------------------------------------- +// model +// --------------------------------------------------------------------------- + +/// Which of an environment's two layers a value belongs to. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum EnvLayer { + /// Pulled from the remote. Replaced wholesale by the next pull. + Synced, + /// Set on this machine. Never leaves it. + Local, +} + +impl EnvLayer { + /// Both layers, in merge order: synced first, local over it. + pub const BOTH: [EnvLayer; 2] = [EnvLayer::Synced, EnvLayer::Local]; + + pub fn as_str(self) -> &'static str { + match self { + Self::Synced => "synced", + Self::Local => "local", + } + } +} + +/// The keychain account one layer's values are filed under: +/// `env://`. +/// +/// The `env:` prefix keeps this namespace clear of the key vault, whose ids are +/// slugs and can therefore never contain `:` or `/`. Everything before the +/// value is metadata a human can read in Keychain Access, which is the point: +/// an item nobody can identify is an item nobody will ever clean up. +pub fn keychain_account(project: &str, env: &str, layer: EnvLayer) -> String { + format!("env:{project}/{env}/{}", layer.as_str()) +} + +/// One environment of one project. **Names only** — no values, and no `last4`. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct EnvMeta { + /// Names present in the synced layer, sorted. + #[serde(default)] + pub synced_names: Vec, + /// Names present in the local layer, sorted. + #[serde(default)] + pub local_names: Vec, + /// When the synced layer was last replaced. `null` until the first pull — + /// an environment can exist with local values alone. + #[serde(default)] + pub synced_at: Option>, +} + +/// Where a project's synced layer comes from. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SyncConfig { + /// `"infisical"` — the only provider today, and validated as such. + pub provider: String, + /// The remote's own project identifier (a UUID, for Infisical). + pub project_id: String, + /// The account the pull must run as. The infisical CLI's active user is + /// machine-global, so recording this is what lets a pull refuse rather than + /// fail confusingly under somebody else's login. + pub account: String, + /// The API base URL, for self-hosted or EU instances. Absent means the + /// CLI's own default. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub domain: Option, + /// patchbay environment name → the remote's slug, for the projects whose + /// remote calls `production` something else. A name that is absent maps to + /// itself. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub env_map: BTreeMap, +} + +impl SyncConfig { + /// The remote's slug for a patchbay environment name. + pub fn remote_env(&self, env: &str) -> String { + self.env_map + .get(env) + .cloned() + .unwrap_or_else(|| env.to_string()) + } +} + +/// One registered project directory. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ProjectEntry { + /// Slug, unique across the registry, e.g. `"pathors"`. + pub id: String, + /// The directory this project *is*: a repo root, usually. + pub root: PathBuf, + /// Which environment `pb env` uses when the command does not say. + pub default_env: String, + pub created_at: DateTime, + /// Environments, by name. Created implicitly by the first write. + #[serde(default)] + pub environments: BTreeMap, + /// Where the synced layer comes from; absent until the project is linked. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sync: Option, +} + +impl ProjectEntry { + pub fn env(&self, env: &str) -> Option<&EnvMeta> { + self.environments.get(env) + } + + /// Environment names, sorted (the map is a `BTreeMap`). + pub fn env_names(&self) -> Vec<&str> { + self.environments.keys().map(String::as_str).collect() + } +} + +/// Where one variable in one environment comes from. The fast path: derived +/// from the two name lists alone, with no keychain access. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum EnvVarSource { + /// Pulled, and not overridden here. + Synced, + /// Set here, and not present in the synced layer. + Local, + /// Set here *and* pulled: the local value is what gets exported. + LocalOverride, +} + +impl EnvVarSource { + pub fn label(&self) -> &'static str { + match self { + Self::Synced => "synced", + Self::Local => "local", + Self::LocalOverride => "local override", + } + } +} + +/// One variable, named but not valued. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct EnvVarInfo { + pub name: String, + pub source: EnvVarSource, +} + +/// An environment's two layers, merged. **Holds values**, which is why it +/// derives nothing: no `Serialize` that could put it in an MCP response by +/// accident, and no `Debug` that could put it in a log line. Callers render it +/// deliberately, field by field. +pub struct MergedEnv { + /// The variables as a consumer would see them: local over synced. + pub vars: BTreeMap, + /// Names that came from the synced layer, sorted. + pub from_synced: Vec, + /// Names that came from the local layer, sorted. + pub from_local: Vec, + /// Names in both — the local value won. Sorted. + pub overridden: Vec, +} + +/// On-disk shape of `projects.json`. +#[derive(Debug, Serialize, Deserialize)] +struct ProjectsFile { + version: u32, + projects: Vec, +} + +// --------------------------------------------------------------------------- +// registry +// --------------------------------------------------------------------------- + +/// The env vault: a metadata file plus a [`Keystore`] for the values. +/// +/// Stateless between calls, like [`crate::keys::KeyRegistry`]: the file is +/// re-read on every operation, so a pull from the CLI is immediately visible to +/// a running MCP server. +pub struct EnvRegistry { + path: PathBuf, + store: Box, +} + +impl EnvRegistry { + /// Bind to an explicit metadata path and keystore. Tests use this with a + /// tempdir and [`crate::keystore::MemoryKeystore`]. + pub fn new(path: impl Into, store: Box) -> Self { + Self { + path: path.into(), + store, + } + } + + /// Bind to the location [`Paths`] reports, with the given keystore. + pub fn with_paths(paths: &Paths, store: Box) -> Self { + Self::new(paths.projects_file(), store) + } + + /// The real vault on this machine: `~/.config/patchbay/projects.json` plus + /// the macOS Keychain. + pub fn detect() -> anyhow::Result { + let paths = Paths::detect()?; + Ok(Self::with_paths( + &paths, + Box::new(SecurityCliKeystore::new()), + )) + } + + pub fn path(&self) -> &Path { + &self.path + } + + pub fn store_name(&self) -> &'static str { + self.store.describe() + } + + // --- reads -------------------------------------------------------------- + + /// Every registered project, oldest registration first. A missing file is + /// an empty vault, not an error. + pub fn projects(&self) -> anyhow::Result> { + Ok(self.load()?.projects) + } + + /// One project, or `None` when nothing is registered under that id. + pub fn get(&self, id: &str) -> anyhow::Result> { + Ok(self.projects()?.into_iter().find(|p| p.id == id)) + } + + /// The project `dir` belongs to: the registered project whose root is `dir` + /// or an ancestor of it. When several match — a repo registered inside a + /// monorepo — the deepest root wins, because that is the more specific + /// answer. + /// + /// A pure path-prefix comparison, with no canonicalization: resolving + /// symlinks here would mean the answer depends on the filesystem's mood, + /// and `/tmp` on macOS is itself a symlink. A checkout reached through a + /// symlinked path therefore will not match — pass `--project` there. + pub fn find_by_dir(&self, dir: &Path) -> anyhow::Result> { + let mut best: Option = None; + for project in self.projects()? { + if !dir.starts_with(&project.root) { + continue; + } + let deeper = match &best { + None => true, + Some(current) => { + project.root.components().count() > current.root.components().count() + } + }; + if deeper { + best = Some(project); + } + } + Ok(best) + } + + /// Every variable name in one environment and where it comes from. + /// + /// **Metadata only** — this never touches the keychain, which is what makes + /// it safe to call on every prompt render. + pub fn list(&self, project_id: &str, env: &str) -> anyhow::Result> { + let project = self.require(project_id)?; + let meta = project + .env(env) + .ok_or_else(|| unknown_env(&project, env))? + .clone(); + + let mut out: Vec = Vec::new(); + for name in &meta.synced_names { + let source = if meta.local_names.contains(name) { + EnvVarSource::LocalOverride + } else { + EnvVarSource::Synced + }; + out.push(EnvVarInfo { + name: name.clone(), + source, + }); + } + for name in &meta.local_names { + if meta.synced_names.contains(name) { + continue; + } + out.push(EnvVarInfo { + name: name.clone(), + source: EnvVarSource::Local, + }); + } + out.sort_by(|a, b| a.name.cmp(&b.name)); + Ok(out) + } + + /// The environment as a consumer would see it: both layers read from the + /// keychain, local over synced. + /// + /// The **only** method that returns values. Every caller is expected to + /// gate it the way the key vault gates `get_secret`. + pub fn merged(&self, project_id: &str, env: &str) -> anyhow::Result { + let project = self.require(project_id)?; + project.env(env).ok_or_else(|| unknown_env(&project, env))?; + + let synced = self.read_blob(&keychain_account(project_id, env, EnvLayer::Synced))?; + let local = self.read_blob(&keychain_account(project_id, env, EnvLayer::Local))?; + + let from_synced: Vec = synced.keys().cloned().collect(); + let from_local: Vec = local.keys().cloned().collect(); + let overridden: Vec = from_local + .iter() + .filter(|name| synced.contains_key(*name)) + .cloned() + .collect(); + + let mut vars = synced; + for (name, value) in local { + vars.insert(name, value); + } + + Ok(MergedEnv { + vars, + from_synced, + from_local, + overridden, + }) + } + + // --- writes ------------------------------------------------------------- + + /// Register a directory as a project. No keychain item is created: an + /// environment appears on the first write to it. + pub fn register( + &self, + id: &str, + root: impl Into, + default_env: &str, + ) -> anyhow::Result { + validate_project_id(id)?; + validate_env_name(default_env)?; + let root = root.into(); + + let mut file = self.load()?; + if let Some(existing) = file.projects.iter().find(|p| p.id == id) { + anyhow::bail!( + "a project is already registered as `{id}` ({}); pick another id, or remove that \ + one with `pb env forget --project {id}`", + existing.root.display() + ); + } + if let Some(existing) = file.projects.iter().find(|p| p.root == root) { + anyhow::bail!( + "{} is already registered as project `{}`; use that project, or remove it with \ + `pb env forget --project {}`", + root.display(), + existing.id, + existing.id + ); + } + + let entry = ProjectEntry { + id: id.to_string(), + root, + default_env: default_env.to_string(), + created_at: Utc::now(), + environments: BTreeMap::new(), + sync: None, + }; + file.projects.push(entry.clone()); + self.save(&file)?; + Ok(entry) + } + + /// Point a project at a remote, replacing whatever it was linked to. + pub fn set_sync(&self, id: &str, sync: SyncConfig) -> anyhow::Result { + if sync.provider != "infisical" { + anyhow::bail!( + "`{}` is not a sync provider patchbay knows; the only one today is `infisical`", + sync.provider + ); + } + for env in sync.env_map.keys() { + validate_env_name(env)?; + } + + let mut file = self.load()?; + let project = project_mut(&mut file, id)?; + project.sync = Some(sync); + let updated = project.clone(); + self.save(&file)?; + Ok(updated) + } + + /// Unregister a project: the metadata entry and every stored value for + /// every environment, both layers. + /// + /// Metadata first, keychain second, with the same both-or-neither rule as + /// the key vault. A value that is already absent is not an error — that is + /// what [`Keystore::delete`] returning `Ok(false)` is for. + pub fn forget(&self, id: &str) -> anyhow::Result { + let mut file = self.load()?; + let at = file + .projects + .iter() + .position(|p| p.id == id) + .ok_or_else(|| unknown_project(id))?; + let entry = file.projects.remove(at); + + let previous = self.read_raw()?; + self.save(&file)?; + for env in entry.environments.keys() { + for layer in EnvLayer::BOTH { + let account = keychain_account(&entry.id, env, layer); + if let Err(e) = self.store.delete(&account) { + // Earlier layers may already be gone. Restoring the metadata + // is still the right move: a registry entry whose values are + // missing can be re-pulled or re-set, whereas a keychain item + // nothing points at can only be found by hand. + self.restore(previous.as_deref())?; + return Err(e.context(format!( + "could not delete the stored {} values for `{id}/{env}`; the project was \ + kept — remove the leftover keychain items by hand, or try again", + layer.as_str() + ))); + } + } + } + Ok(entry) + } + + /// Set one variable in the local layer, creating the environment if this is + /// its first value. + pub fn set_local( + &self, + project_id: &str, + env: &str, + name: &str, + value: &str, + ) -> anyhow::Result<()> { + validate_env_name(env)?; + validate_var_name(name)?; + + let account = keychain_account(project_id, env, EnvLayer::Local); + let mut file = self.load()?; + { + let project = project_mut(&mut file, project_id)?; + let meta = project.environments.entry(env.to_string()).or_default(); + insert_name(&mut meta.local_names, name); + } + let mut vars = self.read_blob(&account)?; + vars.insert(name.to_string(), value.to_string()); + + self.commit_layer( + &file, + &account, + &vars, + &format!("the local values of `{project_id}/{env}`"), + ) + } + + /// Remove one variable from the local layer. + /// + /// Returns the note the caller should show, if any: when the same name is + /// also in the synced layer, dropping the override does not remove the + /// variable — it un-shadows the pulled value, and a user who is not told + /// that will assume the variable is gone. + pub fn unset_local( + &self, + project_id: &str, + env: &str, + name: &str, + ) -> anyhow::Result> { + validate_env_name(env)?; + + let account = keychain_account(project_id, env, EnvLayer::Local); + let mut file = self.load()?; + let note; + { + let project = project_mut(&mut file, project_id)?; + let project_id = project.id.clone(); + let known: Vec = project.environments.keys().cloned().collect(); + let meta = project.environments.get_mut(env).ok_or_else(|| { + let known: Vec<&str> = known.iter().map(String::as_str).collect(); + unknown_env_named(&project_id, env, &known) + })?; + + let Some(at) = meta.local_names.iter().position(|n| n == name) else { + if meta.synced_names.iter().any(|n| n == name) { + anyhow::bail!( + "`{name}` in `{project_id}/{env}` comes from the synced layer, so there \ + is no local override to remove; patchbay never pushes, so a pulled \ + variable can only go away by disappearing from the remote and being \ + pulled again" + ); + } + anyhow::bail!( + "`{name}` is not set in the local layer of `{project_id}/{env}`; \ + `pb env list --project {project_id} --env {env}` shows what is" + ); + }; + meta.local_names.remove(at); + note = meta.synced_names.iter().any(|n| n == name).then(|| { + format!( + "`{name}` is still set by the synced layer of `{project_id}/{env}`; the \ + pulled value is in effect again" + ) + }); + } + + let mut vars = self.read_blob(&account)?; + vars.remove(name); + self.commit_layer( + &file, + &account, + &vars, + &format!("the local values of `{project_id}/{env}`"), + )?; + Ok(note) + } + + /// Merge a batch of variables into the local layer. Returns how many + /// landed. + /// + /// Every name is validated **before** anything is written: half an imported + /// `.env` is worse than none, because the failure is silent at the point it + /// matters — three commands later, when something reads a variable that was + /// never stored. + pub fn import_local( + &self, + project_id: &str, + env: &str, + vars: &[(String, String)], + ) -> anyhow::Result { + validate_env_name(env)?; + for (name, _) in vars { + validate_var_name(name)?; + } + if vars.is_empty() { + // Nothing to store, and no reason to create an environment. + return Ok(0); + } + + let account = keychain_account(project_id, env, EnvLayer::Local); + let mut file = self.load()?; + { + let project = project_mut(&mut file, project_id)?; + let meta = project.environments.entry(env.to_string()).or_default(); + for (name, _) in vars { + insert_name(&mut meta.local_names, name); + } + } + let mut stored = self.read_blob(&account)?; + for (name, value) in vars { + stored.insert(name.clone(), value.clone()); + } + + self.commit_layer( + &file, + &account, + &stored, + &format!("the local values of `{project_id}/{env}`"), + )?; + Ok(vars.len()) + } + + /// Replace the synced layer wholesale. Called by [`crate::env_sync`] and + /// nowhere else. + /// + /// Wholesale is the point: a variable deleted on the remote has to + /// disappear here too, and a merge would keep it forever. The local layer + /// is not read, not written, and not consulted. + pub fn replace_synced( + &self, + project_id: &str, + env: &str, + vars: BTreeMap, + synced_at: DateTime, + ) -> anyhow::Result<()> { + validate_env_name(env)?; + for name in vars.keys() { + validate_var_name(name)?; + } + + let account = keychain_account(project_id, env, EnvLayer::Synced); + let mut file = self.load()?; + { + let project = project_mut(&mut file, project_id)?; + let meta = project.environments.entry(env.to_string()).or_default(); + meta.synced_names = vars.keys().cloned().collect(); + meta.synced_at = Some(synced_at); + } + self.commit_layer( + &file, + &account, + &vars, + &format!("the synced values of `{project_id}/{env}`"), + ) + } + + // --- keychain plumbing -------------------------------------------------- + + /// One layer's values. An absent item is an empty layer, not an error: a + /// pull that returned nothing and an environment that has never been pulled + /// look the same from here, and both are fine. + fn read_blob(&self, account: &str) -> anyhow::Result> { + let Some(raw) = self.store.get(account)? else { + return Ok(BTreeMap::new()); + }; + serde_json::from_str(&raw).map_err(|e| { + anyhow::anyhow!( + "the {} item `{account}` is not a patchbay env set ({e}); delete that item and \ + pull the environment again (`pb env pull`) or re-set its local values \ + (`pb env set`)", + self.store.describe() + ) + }) + } + + /// Write metadata and one layer's blob: both or neither. + /// + /// The metadata file goes first and is restored byte-for-byte if the + /// keystore refuses, so the registry can never claim a variable whose value + /// was never stored. + fn commit_layer( + &self, + file: &ProjectsFile, + account: &str, + vars: &BTreeMap, + what: &str, + ) -> anyhow::Result<()> { + // Compact: this is machine-read, and a pretty-printed blob would triple + // the size of a keychain item for nobody's benefit. + let body = serde_json::to_string(vars)?; + + let previous = self.read_raw()?; + self.save(file)?; + if let Err(e) = self.store.put(account, &body) { + self.restore(previous.as_deref()).map_err(|restore_err| { + anyhow::anyhow!( + "{e}; AND the metadata rollback failed: {restore_err}. {} may now disagree \ + with the {} about {what}", + self.path.display(), + self.store.describe() + ) + })?; + return Err(e.context(format!( + "could not store {what}; metadata rolled back, nothing changed" + ))); + } + Ok(()) + } + + fn require(&self, id: &str) -> anyhow::Result { + self.get(id)?.ok_or_else(|| unknown_project(id)) + } + + // --- file plumbing ------------------------------------------------------ + + fn read_raw(&self) -> anyhow::Result> { + match std::fs::read_to_string(&self.path) { + Ok(text) => Ok(Some(text)), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(anyhow::anyhow!( + "could not read {}: {e}", + self.path.display() + )), + } + } + + fn load(&self) -> anyhow::Result { + let empty = || ProjectsFile { + version: PROJECTS_FILE_VERSION, + projects: Vec::new(), + }; + let Some(text) = self.read_raw()? else { + return Ok(empty()); + }; + if text.trim().is_empty() { + return Ok(empty()); + } + // A malformed registry is a hard error, not an empty one: starting over + // silently would let the next write drop every project on the machine, + // and the keychain items behind them would be orphaned with it. + let file: ProjectsFile = serde_json::from_str(&text).map_err(|e| { + anyhow::anyhow!( + "{} is not a readable patchbay project registry: {e}", + self.path.display() + ) + })?; + if file.version > PROJECTS_FILE_VERSION { + anyhow::bail!( + "{} was written by a newer patchbay (file version {}, this build understands {}); \ + upgrade rather than risk rewriting it", + self.path.display(), + file.version, + PROJECTS_FILE_VERSION + ); + } + Ok(file) + } + + /// Write the registry atomically: temp file in the same directory, `0600`, + /// then rename over the target. + fn save(&self, file: &ProjectsFile) -> anyhow::Result<()> { + let body = serde_json::to_string_pretty(&ProjectsFile { + version: PROJECTS_FILE_VERSION, + projects: file.projects.clone(), + })?; + self.write_atomic(&body) + } + + fn restore(&self, previous: Option<&str>) -> anyhow::Result<()> { + match previous { + Some(text) => self.write_atomic(text), + // There was no file before; removing it is the true rollback. + None => match std::fs::remove_file(&self.path) { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(anyhow::anyhow!( + "could not remove {}: {e}", + self.path.display() + )), + }, + } + } + + fn write_atomic(&self, body: &str) -> anyhow::Result<()> { + let dir = self + .path + .parent() + .ok_or_else(|| anyhow::anyhow!("{} has no parent directory", self.path.display()))?; + std::fs::create_dir_all(dir) + .map_err(|e| anyhow::anyhow!("could not create {}: {e}", dir.display()))?; + + let tmp = self.path.with_extension("json.tmp"); + std::fs::write(&tmp, body) + .map_err(|e| anyhow::anyhow!("could not write {}: {e}", tmp.display()))?; + // Variable names are not secret, but which of them a machine holds is + // nobody else's business. + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600)) + .map_err(|e| anyhow::anyhow!("could not chmod {}: {e}", tmp.display()))?; + } + std::fs::rename(&tmp, &self.path).map_err(|e| { + let _ = std::fs::remove_file(&tmp); + anyhow::anyhow!("could not replace {}: {e}", self.path.display()) + }) + } +} + +// --------------------------------------------------------------------------- +// free functions +// --------------------------------------------------------------------------- + +fn project_mut<'a>(file: &'a mut ProjectsFile, id: &str) -> anyhow::Result<&'a mut ProjectEntry> { + file.projects + .iter_mut() + .find(|p| p.id == id) + .ok_or_else(|| unknown_project(id)) +} + +fn unknown_project(id: &str) -> anyhow::Error { + anyhow::anyhow!("no project registered as `{id}`; register one with `pb env init --id {id}`") +} + +fn unknown_env(project: &ProjectEntry, env: &str) -> anyhow::Error { + unknown_env_named(&project.id, env, &project.env_names()) +} + +fn unknown_env_named(project_id: &str, env: &str, known: &[&str]) -> anyhow::Error { + let known = if known.is_empty() { + "it has none yet".to_string() + } else { + format!("it has {}", known.join(", ")) + }; + anyhow::anyhow!( + "project `{project_id}` has no environment `{env}` ({known}); create one by setting a \ + value (`pb env set --project {project_id} --env {env} NAME` — the value is prompted \ + for, never an argument) or by pulling (`pb env pull --project {project_id} --env {env}`)" + ) +} + +/// Add a name to a sorted, deduplicated name list. +fn insert_name(names: &mut Vec, name: &str) { + if let Err(at) = names.binary_search_by(|n| n.as_str().cmp(name)) { + names.insert(at, name.to_string()); + } +} + +// --------------------------------------------------------------------------- +// dotenv +// --------------------------------------------------------------------------- + +/// Parse `.env` text into name/value pairs, in file order. +/// +/// The dialect is the one everybody actually writes: `#` comments, blank lines, +/// an optional `export ` prefix, and values that are bare, single-quoted +/// (literal) or double-quoted (with `\n`, `\t`, `\r`, `\"` and `\\` escapes). +/// Adjacent quoted runs concatenate the way a shell would, which is what makes +/// the `'\''` idiom in [`render_dotenv`] round-trip. +/// +/// A malformed line is an error naming the **line number and nothing else**: the +/// text of a line that failed to parse is, by definition, a string patchbay does +/// not understand — and the most likely thing it contains is a secret. +pub fn parse_dotenv(text: &str) -> anyhow::Result> { + let mut out = Vec::new(); + for (index, raw) in text.lines().enumerate() { + let line_no = index + 1; + let line = raw.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let line = line + .strip_prefix("export ") + .map(str::trim_start) + .unwrap_or(line); + + let Some((name, rest)) = line.split_once('=') else { + anyhow::bail!("line {line_no} is not `NAME=value`"); + }; + let name = name.trim(); + validate_var_name(name).map_err(|e| anyhow::anyhow!("line {line_no}: {e}"))?; + out.push((name.to_string(), parse_dotenv_value(rest.trim(), line_no)?)); + } + Ok(out) +} + +fn parse_dotenv_value(raw: &str, line_no: usize) -> anyhow::Result { + // An unquoted value is the rest of the line, trimmed. `#` is *not* a comment + // introducer here: `PASSWORD=hunter#2` is a password, not a truncated one. + if !raw.starts_with('\'') && !raw.starts_with('"') { + return Ok(raw.to_string()); + } + + let mut value = String::new(); + let mut chars = raw.chars().peekable(); + while let Some(c) = chars.next() { + match c { + '\'' => { + let mut closed = false; + for c in chars.by_ref() { + if c == '\'' { + closed = true; + break; + } + value.push(c); + } + if !closed { + anyhow::bail!("line {line_no} has an unterminated `'` quote"); + } + } + '"' => { + let mut closed = false; + while let Some(c) = chars.next() { + match c { + '"' => { + closed = true; + break; + } + '\\' => match chars.next() { + Some('n') => value.push('\n'), + Some('t') => value.push('\t'), + Some('r') => value.push('\r'), + Some('"') => value.push('"'), + Some('\\') => value.push('\\'), + // An escape patchbay does not define is left exactly + // as written: `\d` in a regex-shaped value means + // `\d`, and eating the backslash would corrupt it. + Some(other) => { + value.push('\\'); + value.push(other); + } + None => break, + }, + _ => value.push(c), + } + } + if !closed { + anyhow::bail!("line {line_no} has an unterminated `\"` quote"); + } + } + // A backslash outside quotes escapes the next character, which is + // what makes `'a'\''b'` one value of `a'b`. + '\\' => match chars.next() { + Some(next) => value.push(next), + None => value.push('\\'), + }, + c if c.is_whitespace() => { + let rest: String = chars.collect(); + let rest = rest.trim(); + if rest.is_empty() || rest.starts_with('#') { + return Ok(value); + } + anyhow::bail!("line {line_no} has trailing text after a quoted value"); + } + '#' if value.is_empty() => return Ok(value), + c => value.push(c), + } + } + Ok(value) +} + +/// Render variables as `.env` text: sorted, one `NAME='value'` per line, with a +/// trailing newline. +/// +/// Single quotes, because they are the only shell quoting with no escapes +/// inside at all: an embedded `'` closes the string, emits an escaped one and +/// reopens it (`'\''`), and nothing else in the value can mean anything. +/// +/// The exception is a value containing a newline, tab or carriage return, which +/// is written double-quoted with those characters escaped. A literal newline +/// inside single quotes is valid shell but would split the variable across two +/// lines, and every line-based reader of a `.env` file — including +/// [`parse_dotenv`] — would then read it wrong. +pub fn render_dotenv(vars: &BTreeMap) -> String { + let mut out = String::new(); + for (name, value) in vars { + out.push_str(name); + out.push('='); + if value.contains(['\n', '\t', '\r']) { + out.push('"'); + for c in value.chars() { + match c { + '\n' => out.push_str("\\n"), + '\t' => out.push_str("\\t"), + '\r' => out.push_str("\\r"), + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + c => out.push(c), + } + } + out.push('"'); + } else { + out.push('\''); + out.push_str(&value.replace('\'', r"'\''")); + out.push('\''); + } + out.push('\n'); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::keystore::MemoryKeystore; + use std::sync::Arc; + + /// A registry over a tempdir with a fake keystore. Nothing here touches the + /// real `$HOME` or the real keychain. + struct Vault { + _dir: tempfile::TempDir, + registry: EnvRegistry, + store: Arc, + } + + /// `Box` over a shared handle, so a test can inspect the fake + /// after the registry has used it. + struct Shared(Arc); + + impl Keystore for Shared { + fn put(&self, id: &str, secret: &str) -> anyhow::Result<()> { + self.0.put(id, secret) + } + fn get(&self, id: &str) -> anyhow::Result> { + self.0.get(id) + } + fn delete(&self, id: &str) -> anyhow::Result { + self.0.delete(id) + } + fn describe(&self) -> &'static str { + self.0.describe() + } + } + + fn vault_with(store: MemoryKeystore) -> Vault { + let dir = tempfile::tempdir().unwrap(); + // Through Paths, and through a directory that does not exist yet, so + // the first write has to create it. + let paths = Paths::for_test(dir.path()); + let store = Arc::new(store); + let registry = EnvRegistry::with_paths(&paths, Box::new(Shared(store.clone()))); + Vault { + _dir: dir, + registry, + store, + } + } + + fn vault() -> Vault { + vault_with(MemoryKeystore::new()) + } + + /// A registered project with one environment holding both layers. + fn seeded() -> Vault { + let v = vault(); + v.registry + .register("pathors", "/Users/x/repos/pathors", DEFAULT_ENV) + .unwrap(); + v.registry + .replace_synced( + "pathors", + "dev", + [ + ("DATABASE_URL".to_string(), "postgres://remote".to_string()), + ("API_KEY".to_string(), "remote-key".to_string()), + ] + .into_iter() + .collect(), + Utc::now(), + ) + .unwrap(); + v.registry + .set_local("pathors", "dev", "DATABASE_URL", "postgres://localhost") + .unwrap(); + v.registry + .set_local("pathors", "dev", "MY_FLAG", "true") + .unwrap(); + v + } + + fn blob(v: &Vault, env: &str, layer: EnvLayer) -> BTreeMap { + let raw = v + .store + .get(&keychain_account("pathors", env, layer)) + .unwrap() + .expect("no keychain item"); + serde_json::from_str(&raw).unwrap() + } + + // --- registration ------------------------------------------------------- + + #[test] + fn test_register_writes_metadata_and_no_keychain_items() { + let v = vault(); + let entry = v + .registry + .register("pathors", "/repos/pathors", "dev") + .unwrap(); + + assert_eq!(entry.id, "pathors"); + assert_eq!(entry.root, PathBuf::from("/repos/pathors")); + assert_eq!(entry.default_env, "dev"); + assert!(entry.environments.is_empty()); + assert!(entry.sync.is_none()); + + assert_eq!(v.registry.projects().unwrap(), vec![entry]); + // An environment appears on the first write, not on registration. + assert!(v.store.is_empty()); + } + + #[test] + fn test_empty_vault_is_not_an_error() { + let v = vault(); + assert!(v.registry.projects().unwrap().is_empty()); + assert!(v.registry.get("nope").unwrap().is_none()); + assert!(v + .registry + .find_by_dir(Path::new("/anywhere")) + .unwrap() + .is_none()); + assert!(!v.registry.path().exists()); + } + + #[test] + fn test_a_duplicate_id_or_root_names_the_conflict() { + let v = vault(); + v.registry + .register("pathors", "/repos/pathors", "dev") + .unwrap(); + + let err = v + .registry + .register("pathors", "/repos/elsewhere", "dev") + .unwrap_err() + .to_string(); + assert!(err.contains("already registered as `pathors`"), "{err}"); + assert!(err.contains("/repos/pathors"), "{err}"); + + let err = v + .registry + .register("other", "/repos/pathors", "dev") + .unwrap_err() + .to_string(); + assert!( + err.contains("/repos/pathors is already registered"), + "{err}" + ); + assert!(err.contains("`pathors`"), "{err}"); + + assert_eq!(v.registry.projects().unwrap().len(), 1); + } + + #[test] + fn test_register_validates_the_id_and_the_default_env() { + let v = vault(); + let err = v + .registry + .register("Pathors", "/repos/p", "dev") + .unwrap_err() + .to_string(); + assert!(err.contains("project id"), "{err}"); + + let err = v + .registry + .register("pathors", "/repos/p", "Prod") + .unwrap_err() + .to_string(); + assert!(err.contains("environment name"), "{err}"); + assert!(!v.registry.path().exists()); + } + + #[test] + fn test_find_by_dir_prefers_the_deepest_root() { + let v = vault(); + v.registry.register("mono", "/repos/mono", "dev").unwrap(); + v.registry + .register("inner", "/repos/mono/apps/web", "dev") + .unwrap(); + + let hit = |dir: &str| { + v.registry + .find_by_dir(Path::new(dir)) + .unwrap() + .map(|p| p.id) + }; + assert_eq!(hit("/repos/mono"), Some("mono".into())); + assert_eq!(hit("/repos/mono/services/api"), Some("mono".into())); + // The nested project wins for its own subtree. + assert_eq!(hit("/repos/mono/apps/web"), Some("inner".into())); + assert_eq!(hit("/repos/mono/apps/web/src"), Some("inner".into())); + // A sibling with the same prefix as a *string* is not a child path. + assert_eq!(hit("/repos/monolith"), None); + assert_eq!(hit("/elsewhere"), None); + } + + #[test] + fn test_forget_takes_every_layer_of_every_environment() { + let v = seeded(); + v.registry + .set_local("pathors", "staging", "ONLY_HERE", "1") + .unwrap(); + assert_eq!(v.store.len(), 3); + + let entry = v.registry.forget("pathors").unwrap(); + assert_eq!(entry.id, "pathors"); + assert!(v.registry.projects().unwrap().is_empty()); + assert!( + v.store.is_empty(), + "keychain items survived the project: {}", + v.store.len() + ); + + let err = v.registry.forget("pathors").unwrap_err().to_string(); + assert!(err.contains("no project registered as `pathors`"), "{err}"); + } + + #[test] + fn test_forget_keeps_the_project_when_a_delete_fails() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("projects.json"); + let ok = EnvRegistry::new(&path, Box::new(MemoryKeystore::new())); + ok.register("pathors", "/repos/pathors", "dev").unwrap(); + ok.set_local("pathors", "dev", "A", "1").unwrap(); + + let broken = EnvRegistry::new(&path, Box::new(MemoryKeystore::failing_delete())); + let err = format!("{:#}", broken.forget("pathors").unwrap_err()); + assert!(err.contains("the project was kept"), "{err}"); + assert_eq!(broken.projects().unwrap().len(), 1); + } + + #[test] + fn test_set_sync_replaces_and_only_knows_one_provider() { + let v = vault(); + v.registry + .register("pathors", "/repos/pathors", "dev") + .unwrap(); + + let entry = v + .registry + .set_sync( + "pathors", + SyncConfig { + provider: "infisical".into(), + project_id: "3ab516bd-248c-4be7-8f1a-bda73fe69d50".into(), + account: "contact@pathors.com".into(), + domain: None, + env_map: [("production".to_string(), "prod".to_string())] + .into_iter() + .collect(), + }, + ) + .unwrap(); + let sync = entry.sync.unwrap(); + assert_eq!(sync.remote_env("production"), "prod"); + // An unmapped name is its own slug. + assert_eq!(sync.remote_env("dev"), "dev"); + + let err = v + .registry + .set_sync( + "pathors", + SyncConfig { + provider: "vault".into(), + project_id: "x".into(), + account: "a@b.com".into(), + domain: None, + env_map: BTreeMap::new(), + }, + ) + .unwrap_err() + .to_string(); + assert!(err.contains("the only one today is `infisical`"), "{err}"); + // The good config is still in place. + assert!(v.registry.get("pathors").unwrap().unwrap().sync.is_some()); + } + + // --- layers ------------------------------------------------------------- + + #[test] + fn test_values_go_to_the_keychain_and_names_go_to_the_file() { + let v = seeded(); + + assert_eq!( + blob(&v, "dev", EnvLayer::Local), + [ + ( + "DATABASE_URL".to_string(), + "postgres://localhost".to_string() + ), + ("MY_FLAG".to_string(), "true".to_string()), + ] + .into_iter() + .collect() + ); + + let meta = v.registry.get("pathors").unwrap().unwrap().environments["dev"].clone(); + assert_eq!(meta.synced_names, vec!["API_KEY", "DATABASE_URL"]); + assert_eq!(meta.local_names, vec!["DATABASE_URL", "MY_FLAG"]); + assert!(meta.synced_at.is_some()); + + // Not one value reached the file — names and timestamps only. + let raw = std::fs::read_to_string(v.registry.path()).unwrap(); + for value in [ + "postgres://localhost", + "postgres://remote", + "remote-key", + "true", + ] { + assert!(!raw.contains(value), "`{value}` leaked into {raw}"); + } + assert!(raw.contains("DATABASE_URL"), "{raw}"); + } + + #[test] + fn test_one_keychain_item_per_layer() { + let v = seeded(); + let accounts = ["env:pathors/dev/synced", "env:pathors/dev/local"]; + for account in accounts { + assert!(v.store.contains(account), "missing `{account}`"); + } + assert_eq!(v.store.len(), accounts.len()); + } + + #[cfg(unix)] + #[test] + fn test_metadata_file_is_owner_only() { + use std::os::unix::fs::PermissionsExt; + let v = seeded(); + let mode = std::fs::metadata(v.registry.path()) + .unwrap() + .permissions() + .mode(); + assert_eq!(mode & 0o777, 0o600, "mode was {:o}", mode & 0o777); + } + + #[test] + fn test_set_local_creates_the_environment_and_survives_a_reopen() { + let v = vault(); + v.registry + .register("pathors", "/repos/pathors", "dev") + .unwrap(); + v.registry + .set_local("pathors", "staging", "MY_FLAG", "true") + .unwrap(); + + let project = v.registry.get("pathors").unwrap().unwrap(); + assert_eq!(project.env_names(), vec!["staging"]); + + // A second registry over the same file and store sees the same thing. + let reopened = EnvRegistry::new(v.registry.path(), Box::new(Shared(v.store.clone()))); + let merged = reopened.merged("pathors", "staging").unwrap(); + assert_eq!(merged.vars["MY_FLAG"], "true"); + } + + #[test] + fn test_set_local_validates_before_touching_anything() { + let v = vault(); + v.registry + .register("pathors", "/repos/pathors", "dev") + .unwrap(); + + let err = v + .registry + .set_local("pathors", "dev", "1BAD", "x") + .unwrap_err() + .to_string(); + assert!(err.contains("must start with a letter or `_`"), "{err}"); + + let err = v + .registry + .set_local("pathors", "dev", "HAS-DASH", "x") + .unwrap_err() + .to_string(); + assert!(err.contains("contains `-`"), "{err}"); + + let err = v + .registry + .set_local("pathors", "Prod", "OK", "x") + .unwrap_err() + .to_string(); + assert!(err.contains("environment name"), "{err}"); + + let err = v + .registry + .set_local("ghost", "dev", "OK", "x") + .unwrap_err() + .to_string(); + assert!(err.contains("no project registered as `ghost`"), "{err}"); + + assert!(v.store.is_empty()); + assert!(v + .registry + .get("pathors") + .unwrap() + .unwrap() + .environments + .is_empty()); + } + + #[test] + fn test_unset_local_says_the_synced_value_is_back() { + let v = seeded(); + let note = v + .registry + .unset_local("pathors", "dev", "DATABASE_URL") + .unwrap() + .expect("an overridden name deserves a note"); + assert!(note.contains("still set by the synced layer"), "{note}"); + + let merged = v.registry.merged("pathors", "dev").unwrap(); + assert_eq!(merged.vars["DATABASE_URL"], "postgres://remote"); + assert!(merged.overridden.is_empty()); + + // A purely local name goes quietly. + assert_eq!( + v.registry.unset_local("pathors", "dev", "MY_FLAG").unwrap(), + None + ); + assert!(!v + .registry + .merged("pathors", "dev") + .unwrap() + .vars + .contains_key("MY_FLAG")); + } + + #[test] + fn test_unset_local_refuses_a_synced_only_name() { + let v = seeded(); + let err = v + .registry + .unset_local("pathors", "dev", "API_KEY") + .unwrap_err() + .to_string(); + assert!(err.contains("comes from the synced layer"), "{err}"); + assert!(err.contains("patchbay never pushes"), "{err}"); + + let err = v + .registry + .unset_local("pathors", "dev", "NEVER_SET") + .unwrap_err() + .to_string(); + assert!(err.contains("not set in the local layer"), "{err}"); + + // Both refusals changed nothing. + assert_eq!( + v.registry.list("pathors", "dev").unwrap().len(), + 3, + "a refusal modified the registry" + ); + } + + #[test] + fn test_import_local_is_all_or_nothing() { + let v = vault(); + v.registry + .register("pathors", "/repos/pathors", "dev") + .unwrap(); + + let good: Vec<(String, String)> = vec![ + ("A".into(), "1".into()), + ("B".into(), "2".into()), + ("_C".into(), "3".into()), + ]; + assert_eq!(v.registry.import_local("pathors", "dev", &good).unwrap(), 3); + assert_eq!(v.registry.merged("pathors", "dev").unwrap().vars.len(), 3); + + let mut bad = good.clone(); + bad.push(("no good".into(), "4".into())); + assert!(v.registry.import_local("pathors", "dev", &bad).is_err()); + // Not even the valid half of the batch landed. + assert_eq!(v.registry.merged("pathors", "dev").unwrap().vars.len(), 3); + + // A merge, not a replacement. + v.registry + .import_local("pathors", "dev", &[("D".into(), "4".into())]) + .unwrap(); + let merged = v.registry.merged("pathors", "dev").unwrap(); + assert_eq!(merged.vars.len(), 4); + assert_eq!(merged.vars["A"], "1"); + + // Nothing to import creates nothing. + assert_eq!(v.registry.import_local("pathors", "other", &[]).unwrap(), 0); + assert!(v + .registry + .get("pathors") + .unwrap() + .unwrap() + .env("other") + .is_none()); + } + + #[test] + fn test_replace_synced_is_wholesale_and_leaves_local_alone() { + let v = seeded(); + let at = DateTime::parse_from_rfc3339("2026-08-13T01:00:00Z") + .unwrap() + .with_timezone(&Utc); + v.registry + .replace_synced( + "pathors", + "dev", + [("ONLY_ONE".to_string(), "kept".to_string())] + .into_iter() + .collect(), + at, + ) + .unwrap(); + + let meta = v.registry.get("pathors").unwrap().unwrap().environments["dev"].clone(); + // The old synced names are gone: a variable deleted upstream has to + // disappear here too. + assert_eq!(meta.synced_names, vec!["ONLY_ONE"]); + assert_eq!(meta.synced_at, Some(at)); + // The local layer is untouched, values included. + assert_eq!(meta.local_names, vec!["DATABASE_URL", "MY_FLAG"]); + let merged = v.registry.merged("pathors", "dev").unwrap(); + assert_eq!(merged.vars["DATABASE_URL"], "postgres://localhost"); + assert_eq!(merged.vars["ONLY_ONE"], "kept"); + assert!(!merged.vars.contains_key("API_KEY")); + } + + #[test] + fn test_merged_puts_local_over_synced_and_reports_the_overlap() { + let v = seeded(); + let merged = v.registry.merged("pathors", "dev").unwrap(); + + assert_eq!(merged.vars["DATABASE_URL"], "postgres://localhost"); + assert_eq!(merged.vars["API_KEY"], "remote-key"); + assert_eq!(merged.vars["MY_FLAG"], "true"); + assert_eq!(merged.from_synced, vec!["API_KEY", "DATABASE_URL"]); + assert_eq!(merged.from_local, vec!["DATABASE_URL", "MY_FLAG"]); + assert_eq!(merged.overridden, vec!["DATABASE_URL"]); + } + + #[test] + fn test_list_is_metadata_only() { + let v = seeded(); + // A store that panics the test if it is read at all: `list` is the fast + // path, and reaching the keychain here would be the bug. + struct NoReads; + impl Keystore for NoReads { + fn put(&self, _: &str, _: &str) -> anyhow::Result<()> { + unreachable!("list must not write") + } + fn get(&self, id: &str) -> anyhow::Result> { + panic!("list read the keychain item `{id}`") + } + fn delete(&self, _: &str) -> anyhow::Result { + unreachable!("list must not delete") + } + fn describe(&self) -> &'static str { + "a keystore that must not be read" + } + } + + let quiet = EnvRegistry::new(v.registry.path(), Box::new(NoReads)); + let listed = quiet.list("pathors", "dev").unwrap(); + let seen: Vec<(&str, EnvVarSource)> = + listed.iter().map(|v| (v.name.as_str(), v.source)).collect(); + assert_eq!( + seen, + vec![ + ("API_KEY", EnvVarSource::Synced), + ("DATABASE_URL", EnvVarSource::LocalOverride), + ("MY_FLAG", EnvVarSource::Local), + ] + ); + assert_eq!(EnvVarSource::LocalOverride.label(), "local override"); + } + + #[test] + fn test_an_unknown_environment_says_which_ones_exist() { + let v = seeded(); + let err = v.registry.list("pathors", "prod").unwrap_err().to_string(); + assert!(err.contains("has no environment `prod`"), "{err}"); + assert!(err.contains("it has dev"), "{err}"); + + // `.err()` rather than `.unwrap_err()`: `MergedEnv` has no `Debug`, on + // purpose, and that is worth keeping even in a test. + let err = v + .registry + .merged("pathors", "prod") + .err() + .unwrap() + .to_string(); + assert!(err.contains("has no environment `prod`"), "{err}"); + + let v = vault(); + v.registry.register("fresh", "/repos/fresh", "dev").unwrap(); + let err = v.registry.list("fresh", "dev").unwrap_err().to_string(); + assert!(err.contains("it has none yet"), "{err}"); + } + + // --- both-or-neither ---------------------------------------------------- + + #[test] + fn test_a_keystore_failure_rolls_the_metadata_back() { + let v = vault_with(MemoryKeystore::failing_put()); + v.registry + .register("pathors", "/repos/pathors", "dev") + .unwrap(); + let before = std::fs::read_to_string(v.registry.path()).unwrap(); + + let err = format!( + "{:#}", + v.registry + .set_local("pathors", "dev", "NEVER", "stored") + .unwrap_err() + ); + assert!(err.contains("metadata rolled back"), "{err}"); + + // The environment was never created, and the file is byte-identical. + assert_eq!(std::fs::read_to_string(v.registry.path()).unwrap(), before); + assert!(v + .registry + .get("pathors") + .unwrap() + .unwrap() + .environments + .is_empty()); + assert!(v.store.is_empty()); + } + + #[test] + fn test_a_failed_pull_leaves_the_previous_synced_names_in_place() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("projects.json"); + let store = Arc::new(MemoryKeystore::new()); + let ok = EnvRegistry::new(&path, Box::new(Shared(store.clone()))); + ok.register("pathors", "/repos/pathors", "dev").unwrap(); + ok.replace_synced( + "pathors", + "dev", + [("KEEP".to_string(), "1".to_string())] + .into_iter() + .collect(), + Utc::now(), + ) + .unwrap(); + let before = std::fs::read_to_string(&path).unwrap(); + + let broken = EnvRegistry::new(&path, Box::new(MemoryKeystore::failing_put())); + assert!(broken + .replace_synced( + "pathors", + "dev", + [("NEW".to_string(), "2".to_string())].into_iter().collect(), + Utc::now(), + ) + .is_err()); + + assert_eq!(std::fs::read_to_string(&path).unwrap(), before); + assert_eq!(ok.merged("pathors", "dev").unwrap().vars["KEEP"], "1"); + } + + // --- the file ----------------------------------------------------------- + + #[test] + fn test_a_malformed_registry_is_an_error_not_an_empty_one() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("projects.json"); + std::fs::write(&path, "{ this is not json").unwrap(); + let registry = EnvRegistry::new(&path, Box::new(MemoryKeystore::new())); + let err = registry.projects().unwrap_err().to_string(); + assert!( + err.contains("not a readable patchbay project registry"), + "{err}" + ); + } + + #[test] + fn test_a_newer_file_version_is_refused() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("projects.json"); + std::fs::write(&path, r#"{"version":99,"projects":[]}"#).unwrap(); + let registry = EnvRegistry::new(&path, Box::new(MemoryKeystore::new())); + let err = registry.projects().unwrap_err().to_string(); + assert!(err.contains("newer patchbay"), "{err}"); + } + + #[test] + fn test_a_hand_trimmed_file_still_parses() { + // Only the fields a human would keep: no `environments`, no `sync`. + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("projects.json"); + std::fs::write( + &path, + r#"{ + "version": 1, + "projects": [ + { + "id": "pathors", + "root": "/Users/x/repos/pathors", + "default_env": "dev", + "created_at": "2026-08-13T00:00:00Z" + } + ] +}"#, + ) + .unwrap(); + + let registry = EnvRegistry::new(&path, Box::new(MemoryKeystore::new())); + let projects = registry.projects().unwrap(); + assert_eq!(projects.len(), 1); + assert!(projects[0].environments.is_empty()); + assert!(projects[0].sync.is_none()); + + // And a project with no sync does not grow one on rewrite. + registry.set_local("pathors", "dev", "A", "1").unwrap(); + let rewritten = std::fs::read_to_string(&path).unwrap(); + assert!(!rewritten.contains("\"sync\""), "{rewritten}"); + assert!(rewritten.contains("\"synced_at\": null"), "{rewritten}"); + } + + #[test] + fn test_an_unreadable_keychain_blob_names_the_account() { + let v = seeded(); + v.store + .put("env:pathors/dev/local", "not json at all") + .unwrap(); + let err = v + .registry + .merged("pathors", "dev") + .err() + .unwrap() + .to_string(); + assert!(err.contains("env:pathors/dev/local"), "{err}"); + assert!(err.contains("pb env"), "{err}"); + } + + #[test] + fn test_keychain_accounts_cannot_collide_with_key_ids() { + assert_eq!( + keychain_account("pathors", "dev", EnvLayer::Synced), + "env:pathors/dev/synced" + ); + assert_eq!( + keychain_account("pathors", "production", EnvLayer::Local), + "env:pathors/production/local" + ); + // Whatever a key id is, it is a slug — so it can never look like this. + assert!(crate::keys::validate_id("env:pathors/dev/local").is_err()); + } + + // --- validation --------------------------------------------------------- + + #[test] + fn test_var_name_validation() { + for good in ["A", "_", "_x9", "DATABASE_URL", "a_b_C_1"] { + assert!( + validate_var_name(good).is_ok(), + "`{good}` should be allowed" + ); + } + for bad in ["", "1UP", "HAS-DASH", "has space", "A.B", "ÜBER"] { + assert!( + validate_var_name(bad).is_err(), + "`{bad}` should be rejected" + ); + } + } + + // --- dotenv ------------------------------------------------------------- + + #[test] + fn test_parse_dotenv_covers_the_dialect_people_actually_write() { + let parsed = parse_dotenv( + "# a comment\n\ + \n\ + PLAIN=value\n\ + SPACED = trimmed \n\ + export EXPORTED=yes\n\ + SINGLE='literal $NOT_EXPANDED \\n'\n\ + DOUBLE=\"line\\nbreak\\ttab \\\"quoted\\\" back\\\\slash\"\n\ + EMPTY=\n\ + HASH=hunter#2\n\ + QUOTED_THEN_COMMENT='v' # trailing comment\n\ + REGEXISH=\"\\d+\"\n", + ) + .unwrap(); + + let vars: BTreeMap = parsed.iter().cloned().collect(); + assert_eq!(vars["PLAIN"], "value"); + assert_eq!(vars["SPACED"], "trimmed"); + assert_eq!(vars["EXPORTED"], "yes"); + assert_eq!(vars["SINGLE"], "literal $NOT_EXPANDED \\n"); + assert_eq!(vars["DOUBLE"], "line\nbreak\ttab \"quoted\" back\\slash"); + assert_eq!(vars["EMPTY"], ""); + // `#` inside a bare value is part of the value, not a comment. + assert_eq!(vars["HASH"], "hunter#2"); + assert_eq!(vars["QUOTED_THEN_COMMENT"], "v"); + assert_eq!(vars["REGEXISH"], "\\d+"); + // File order is preserved for the caller that wants it. + assert_eq!(parsed[0].0, "PLAIN"); + } + + #[test] + fn test_parse_dotenv_errors_name_the_line_and_never_the_value() { + let cases = [ + ("A=1\nthis is not a pair\n", 2, "not `NAME=value`"), + ("A=1\n\nB='unterminated\n", 3, "unterminated"), + ("B=\"unterminated\n", 1, "unterminated"), + ("A=1\n1BAD=2\n", 2, "line 2:"), + ("A='x' then junk\n", 1, "trailing text"), + ]; + for (text, line, needle) in cases { + let err = parse_dotenv(text).unwrap_err().to_string(); + assert!(err.contains(needle), "{err}"); + assert!(err.contains(&format!("line {line}")), "{err}"); + } + + // The offending line's text is never echoed — it is the likeliest place + // for a secret to be. + let err = parse_dotenv("DATABASE_URL postgres://user:hunter2@db\n") + .unwrap_err() + .to_string(); + assert!(!err.contains("hunter2"), "{err}"); + } + + #[test] + fn test_render_dotenv_round_trips_through_the_parser() { + let vars: BTreeMap = [ + ("PLAIN", "value"), + ("SPACES", "a b c"), + ("QUOTE", "it's got one"), + ("BOTH", "it's \"quoted\""), + ("DOLLAR", "$NOT_EXPANDED `nor this`"), + ("BACKSLASH", "C:\\path\\to"), + ("MULTILINE", "first\nsecond\twide"), + ("EMPTY", ""), + ("HASH", "a # b"), + ] + .into_iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(); + + let text = render_dotenv(&vars); + assert!(text.ends_with('\n')); + // Sorted, one per line. + let names: Vec<&str> = text.lines().map(|l| l.split_once('=').unwrap().0).collect(); + let mut sorted = names.clone(); + sorted.sort(); + assert_eq!(names, sorted); + assert!(text.contains(r"QUOTE='it'\''s got one'"), "{text}"); + + let back: BTreeMap = parse_dotenv(&text).unwrap().into_iter().collect(); + assert_eq!(back, vars); + + assert_eq!(render_dotenv(&BTreeMap::new()), ""); + } +} diff --git a/crates/patchbay-core/src/keys.rs b/crates/patchbay-core/src/keys.rs index 98da80d..2e45515 100644 --- a/crates/patchbay-core/src/keys.rs +++ b/crates/patchbay-core/src/keys.rs @@ -633,31 +633,41 @@ pub fn expiring_within_at(entries: &[KeyEntry], now: DateTime, days: i64) - /// keeps an id from being mistaken for an option when it is handed to /// `security` as an argument. pub fn validate_id(id: &str) -> anyhow::Result<()> { - if id.is_empty() { - anyhow::bail!("a key id cannot be empty"); + validate_slug("key id", id) +} + +/// The slug rules of [`validate_id`], for the other things patchbay names the +/// same way — project ids and environment names (see [`crate::envs`]). +/// +/// `noun` is what the value *is*, singular and lowercase: an error has to say +/// "environment name `Prod`" rather than blaming a key id the caller never +/// mentioned. It is pluralised with a bare `s`, so keep it a plain noun phrase. +pub fn validate_slug(noun: &str, value: &str) -> anyhow::Result<()> { + if value.is_empty() { + anyhow::bail!("{noun} cannot be empty"); } - if id.len() > MAX_ID_LEN { - anyhow::bail!("key id `{id}` is longer than {MAX_ID_LEN} characters"); + if value.len() > MAX_ID_LEN { + anyhow::bail!("{noun} `{value}` is longer than {MAX_ID_LEN} characters"); } - if id.chars().any(|c| c.is_ascii_uppercase()) { + if value.chars().any(|c| c.is_ascii_uppercase()) { anyhow::bail!( - "key ids are lowercase slugs; try `{}`", - id.to_ascii_lowercase() + "{noun}s are lowercase slugs; try `{}`", + value.to_ascii_lowercase() ); } - if !id + if !value .chars() .next() .is_some_and(|c| c.is_ascii_lowercase() || c.is_ascii_digit()) { - anyhow::bail!("key id `{id}` must start with a letter or digit"); + anyhow::bail!("{noun} `{value}` must start with a letter or digit"); } - if let Some(bad) = id + if let Some(bad) = value .chars() .find(|c| !(c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '-' | '_' | '.'))) { anyhow::bail!( - "key id `{id}` contains `{bad}`; use lowercase letters, digits, `-`, `_` and `.`" + "{noun} `{value}` contains `{bad}`; use lowercase letters, digits, `-`, `_` and `.`" ); } Ok(()) @@ -1280,6 +1290,22 @@ mod tests { assert!(validate_id(&"x".repeat(MAX_ID_LEN + 1)).is_err()); } + #[test] + fn test_validate_slug_names_the_thing_it_rejected() { + // The env vault validates project ids and environment names with these + // same rules; the error must not talk about key ids. + for (noun, value) in [("project id", "My Repo"), ("environment name", "Prod")] { + let err = validate_slug(noun, value).unwrap_err().to_string(); + assert!(err.contains(noun), "{err}"); + assert!(!err.contains("key id"), "{err}"); + } + assert!(validate_slug("environment name", "") + .unwrap_err() + .to_string() + .contains("environment name cannot be empty")); + assert!(validate_slug("project id", "pathors").is_ok()); + } + #[test] fn test_add_rejects_a_bad_id_before_touching_anything() { let v = vault(); diff --git a/crates/patchbay-core/src/lib.rs b/crates/patchbay-core/src/lib.rs index bfcdcce..c357348 100644 --- a/crates/patchbay-core/src/lib.rs +++ b/crates/patchbay-core/src/lib.rs @@ -25,9 +25,14 @@ //! keys that no CLI tracks, which the user (or an AI agent) hands to patchbay on //! purpose. Even there the split holds — metadata goes to a JSON file, the value //! goes straight to the OS keychain ([`keystore`]), and -//! [`KeyRegistry::get_secret`] is the single, gated way back out. +//! [`KeyRegistry::get_secret`] is the single, gated way back out. The **project +//! env vault** ([`envs`]) holds the same line for the variable sets a repo needs +//! — names and provenance on disk, values in the keychain, and pull-only sync +//! ([`env_sync`]) so patchbay can never push a local value to a shared remote. pub mod deprecations; +pub mod env_sync; +pub mod envs; pub mod keys; pub mod keys_verify; pub mod keystore; @@ -42,6 +47,10 @@ pub mod util; pub mod versions; pub use deprecations::{Advisory, AdvisoryKind}; +pub use env_sync::{pull, PullOutcome}; +pub use envs::{ + EnvLayer, EnvMeta, EnvRegistry, EnvVarInfo, EnvVarSource, MergedEnv, ProjectEntry, SyncConfig, +}; pub use keys::{KeyEntry, KeyExpiryState, KeyPatch, KeyRegistry, NewKey}; pub use keys_verify::{verify_key, KeyVerifyOutcome, KeyVerifyStatus}; pub use keystore::Keystore; diff --git a/crates/patchbay-core/src/paths.rs b/crates/patchbay-core/src/paths.rs index 6a27f8f..7aa3465 100644 --- a/crates/patchbay-core/src/paths.rs +++ b/crates/patchbay-core/src/paths.rs @@ -647,6 +647,13 @@ impl Paths { self.patchbay_dir().join("keys.json") } + /// The project env vault's metadata registry. Variable *names* and their + /// provenance live here; the values never do — they are in the OS keychain + /// (see [`crate::envs`]). + pub fn projects_file(&self) -> PathBuf { + self.patchbay_dir().join("projects.json") + } + /// The version-check cache (see [`crate::versions`]). Public information /// about public software — no secrets, so no 0600 handling. pub fn versions_file(&self) -> PathBuf { @@ -680,8 +687,13 @@ mod tests { p.keys_file(), PathBuf::from("/nowhere/.config/patchbay/keys.json") ); + assert_eq!( + p.projects_file(), + PathBuf::from("/nowhere/.config/patchbay/projects.json") + ); let p = Paths::for_test("/nowhere").with_env("PATCHBAY_CONFIG_DIR", "/custom/pb"); assert_eq!(p.keys_file(), PathBuf::from("/custom/pb/keys.json")); + assert_eq!(p.projects_file(), PathBuf::from("/custom/pb/projects.json")); } #[test] diff --git a/crates/patchbay-core/src/probes/infisical.rs b/crates/patchbay-core/src/probes/infisical.rs index dbbed94..cdd0568 100644 --- a/crates/patchbay-core/src/probes/infisical.rs +++ b/crates/patchbay-core/src/probes/infisical.rs @@ -50,6 +50,25 @@ struct LoggedInUser { domain: Option, } +/// The account the `infisical` CLI would act as right now, or `None` when +/// nobody is logged in on this machine. +/// +/// Machine-global state, and the reason [`crate::env_sync`] needs it: an +/// `infisical export` runs as whoever this says, not as whoever the caller had +/// in mind. A missing config is a normal "never logged in" answer, not an +/// error; a *malformed* one is an error, because guessing would be worse. +pub fn active_account(paths: &Paths) -> anyhow::Result> { + let path = paths.infisical_config(); + let Some(text) = read_text(&path).map_err(anyhow::Error::msg)? else { + return Ok(None); + }; + let config: Config = serde_json::from_str(&text) + .map_err(|e| anyhow::anyhow!("{} is not valid JSON ({e})", path.display()))?; + Ok(config + .logged_in_user_email + .filter(|email| !email.trim().is_empty())) +} + impl InfisicalProbe { pub const TOOL: &'static str = "infisical"; /// The field naming the active account. @@ -332,6 +351,30 @@ mod tests { assert!(!json.to_lowercase().contains("passphrase"), "{json}"); } + #[test] + fn test_active_account_reads_the_machine_global_login() { + let (_dir, home) = fixture( + r#"{"loggedInUserEmail":"b@example.com","loggedInUsers":[{"email":"a@example.com"},{"email":"b@example.com"}],"vaultBackendPassphrase":"ZmFrZQ=="}"#, + ); + assert_eq!( + active_account(&Paths::for_test(&home)).unwrap().as_deref(), + Some("b@example.com") + ); + + // Never logged in: no file, or a file with no active account. + let dir = tempfile::tempdir().unwrap(); + assert_eq!(active_account(&Paths::for_test(dir.path())).unwrap(), None); + let (_dir, home) = fixture(r#"{"loggedInUsers":[]}"#); + assert_eq!(active_account(&Paths::for_test(&home)).unwrap(), None); + + // Unreadable is an error, not a silent "logged out". + let (_dir, home) = fixture("{ this is not json"); + let err = active_account(&Paths::for_test(&home)) + .unwrap_err() + .to_string(); + assert!(err.contains("not valid JSON"), "{err}"); + } + #[test] fn test_active_user_missing_from_the_list_is_still_a_profile() { let (_dir, home) = diff --git a/crates/patchbay-mcp/src/envs.rs b/crates/patchbay-mcp/src/envs.rs new file mode 100644 index 0000000..065f0db --- /dev/null +++ b/crates/patchbay-mcp/src/envs.rs @@ -0,0 +1,658 @@ +//! The project env vault's MCP surface. +//! +//! [`crate::keys`] exposes the credentials that belong to the *human*. This +//! module exposes the other half: the environment variables one *directory* +//! needs, per environment, in two layers — `synced` (a local mirror of the +//! project's remote, refreshed wholesale by `pull_env`) and `local` (set on +//! this machine, never pushed, and it wins on merge). +//! +//! **There is deliberately no read tool.** Nothing here returns a variable's +//! value: not gated behind [`crate::keys::ALLOW_SECRET_READ`], not gated behind +//! anything — the tool simply does not exist in v1. An agent that needs the +//! values is asking for the wrong thing; the human paths are `pb env run -- +//! ` and `pb env export` in a terminal, where the values never pass +//! through a model's context at all. Everything here is therefore metadata +//! (names, counts, provenance) or a one-way write. +//! +//! Kept in its own `#[tool_router]` impl block, merged into the main router in +//! [`crate::server`], the same way the key vault's tools are. + +use std::collections::BTreeSet; + +use patchbay_core::env_sync; +use patchbay_core::envs::ProjectEntry; +use patchbay_core::{EnvRegistry, Paths}; +use rmcp::handler::server::wrapper::Parameters; +use rmcp::model::CallToolResult; +use rmcp::{tool, tool_router, ErrorData}; +use schemars::JsonSchema; +use serde::Deserialize; + +use crate::server::{encode, json_ok, offload, tool_error, PatchbayServer}; + +// --------------------------------------------------------------------------- +// parameters +// --------------------------------------------------------------------------- + +/// `{ "project": "pathors", "env": "staging" }` — one project, optionally one +/// of its environments. +#[derive(Debug, Deserialize, JsonSchema)] +pub struct EnvSelectorParams { + /// The project's id, exactly as `list_env_projects` reports it (a lowercase + /// slug like "pathors"). This is patchbay's own name for the directory, not + /// a path and not the remote's project id — look it up rather than guessing + /// from the repo name. + pub project: String, + /// Which environment: "dev", "staging", "production". Omit it to use the + /// project's `default_env`, which is what a user who did not say means. + /// Only name one when the user did, or when the task is unambiguously about + /// another environment — reading or writing the wrong environment is the + /// mistake this field exists to make visible. + pub env: Option, +} + +/// `{ "project": "pathors", "name": "STRIPE_KEY", "value": "sk_live_…" }`. +#[derive(Debug, Deserialize, JsonSchema)] +pub struct SetEnvVarParams { + /// The project's id, as `list_env_projects` reports it. + pub project: String, + /// Which environment to write to. Omit it for the project's `default_env`. + /// A credential you created for staging must not land in production + /// because the field was left off — if the user named an environment, name + /// it here. + pub env: Option, + /// The variable's name, as a POSIX shell would export it: + /// `[A-Za-z_][A-Za-z0-9_]*`. Use the name the code already reads + /// (`DATABASE_URL`, `STRIPE_SECRET_KEY`), not a description of it. + pub name: String, + /// The value. It goes straight into the OS keychain, is never written to + /// the metadata file, and is never echoed back by this or any other tool. + /// Send it here and NOWHERE else — not into a `.env` file you are editing, + /// not into your reply, not into a commit, not into a log. + pub value: String, +} + +// --------------------------------------------------------------------------- +// shaping +// --------------------------------------------------------------------------- + +/// The environment a call means: the one it named, or the project's default. +/// +/// An empty or whitespace-only `env` is treated as absent rather than as an +/// invalid name — a client that fills optional strings with `""` should get the +/// default, not a validation error about a name nobody typed. +fn resolve_env(project: &ProjectEntry, requested: Option<&str>) -> String { + match requested.map(str::trim) { + Some(env) if !env.is_empty() => env.to_string(), + _ => project.default_env.clone(), + } +} + +/// The error for a project id nothing is registered under. Names the tool that +/// lists the real ids, so the caller can self-correct without a round trip +/// through the user. +fn unknown_project(id: &str) -> anyhow::Error { + anyhow::anyhow!( + "no project registered as `{id}`; `list_env_projects` shows the ids that exist, and \ + `pb env init --id {id}` registers a new directory in a terminal" + ) +} + +fn require(envs: &EnvRegistry, id: &str) -> anyhow::Result { + envs.get(id)?.ok_or_else(|| unknown_project(id)) +} + +/// One project as the tools report it: registration, environments with counts, +/// and the sync config. **Metadata only** — this cannot carry a value, because +/// [`ProjectEntry`] does not hold one. +fn describe_project(project: &ProjectEntry) -> Result { + let mut environments = Vec::with_capacity(project.environments.len()); + for (name, meta) in &project.environments { + // The merged view is a union, not a sum: a local override shares its + // name with the synced variable it shadows. + let distinct: BTreeSet<&String> = meta + .synced_names + .iter() + .chain(meta.local_names.iter()) + .collect(); + environments.push(serde_json::json!({ + "name": name, + "var_count": distinct.len(), + "synced_count": meta.synced_names.len(), + "local_count": meta.local_names.len(), + "synced_at": encode(&meta.synced_at)?, + })); + } + + Ok(serde_json::json!({ + "id": project.id, + // `display()` rather than serializing the PathBuf: a path that is not + // valid UTF-8 would fail to serialize, and a lossy path is a far better + // answer here than a failed tool call. + "root": project.root.display().to_string(), + "default_env": project.default_env, + "created_at": encode(&project.created_at)?, + "environments": environments, + "sync": match &project.sync { + Some(sync) => encode(sync)?, + None => serde_json::Value::Null, + }, + })) +} + +/// What `set_env_var` reports back. Built here, and tested here, because the +/// one thing it must never contain is the value it just stored. +fn stored_summary(project: &str, env: &str, name: &str, shadows_synced: bool) -> serde_json::Value { + let note = if shadows_synced { + format!( + "`{name}` also exists in the synced layer of `{project}/{env}`; the local value now \ + shadows it, and will keep shadowing it after every future pull_env" + ) + } else { + format!("`{name}` is set only in the local layer of `{project}/{env}`") + }; + serde_json::json!({ + "project": project, + "env": env, + "name": name, + "layer": "local", + "shadows_synced": shadows_synced, + "note": note, + }) +} + +// --------------------------------------------------------------------------- +// tools +// --------------------------------------------------------------------------- + +#[tool_router(router = envs_router, vis = "pub(crate)")] +impl PatchbayServer { + #[tool(description = "\ +CHEAP, SAFE. Every project directory registered in this machine's env vault — metadata only. \ +Reads one local JSON file: no keychain access, no network, no variable values. + +The env vault is a different thing from the key vault. A key belongs to the HUMAN across \ +projects; these are the environment variables ONE directory needs before it will boot — the \ +contents of what would otherwise be an undocumented `.env`. Each environment has two layers: \ +`synced` (mirrored from the project's remote secret manager by pull_env, replaced wholesale by \ +the next pull) and `local` (set on this machine by set_env_var, never pushed anywhere, and it \ +WINS over a synced variable of the same name). + +Call this first when the user mentions their project's env, when you are about to set a variable \ +and need the project id and the environment names, or when you want to know whether a directory \ +is registered at all. + +Returns a JSON array of projects: { id, root, default_env, created_at, environments[], sync }. + +- `id` is patchbay's slug for the directory and the value every other env tool takes as \ +`project`. `root` is the directory it means. +- `default_env` is the environment used when a call omits `env`. Do not assume it is 'dev'. +- `environments[]` is { name, var_count, synced_count, local_count, synced_at }. `var_count` is \ +the distinct names a consumer would see (a local override shares its name with the synced \ +variable it shadows, so the counts do not simply add up). `synced_at: null` means this \ +environment has NEVER been pulled — it exists on local values alone, which is normal, not broken. +- `sync` is the remote this project pulls from — { provider, project_id, account, domain, \ +env_map } — or null when the project has never been linked. `account` is the login a pull must \ +run as; `env_map` is patchbay's environment name -> the remote's own slug, for remotes that call \ +`production` something else. A null `sync` is why a pull_env would fail, and the fix is \ +`pb env link` in a terminal. +- Variable NAMES are not listed here; use list_env_vars for one environment. Variable VALUES are \ +not returned by this or any other tool.")] + async fn list_env_projects(&self) -> Result { + let envs = self.envs.clone(); + match offload(move || envs.projects()).await? { + Ok(projects) => { + let described: Result, ErrorData> = + projects.iter().map(describe_project).collect(); + Ok(json_ok(serde_json::Value::Array(described?))) + } + Err(err) => Ok(tool_error(err)), + } + } + + #[tool(description = "\ +CHEAP, SAFE. Which variables one environment of one project holds, and where each one comes \ +from. Metadata only: this reads the registry file and NEVER touches the keychain, so it costs \ +milliseconds and cannot leak a value. + +Use it to answer 'is DATABASE_URL set for staging?', to check whether a variable you are about \ +to write already exists, and to explain a misconfiguration — a variable an agent expects to be \ +synced but which is actually a stale local override is a very common cause of 'it works on the \ +remote but not here'. + +Omit `env` to get the project's default environment. Returns { project, env, default_env, count, \ +vars: [{ name, source }] }, sorted by name. + +`source` is derived from the two name lists, and is the field worth reading: + +- 'synced' — pulled from the remote, not overridden here. The next pull_env can change or remove \ +it. +- 'local' — set on this machine only. It is not on the remote, and patchbay will never push it \ +there; if a teammate needs it, they must add it to the remote themselves. +- 'local_override' — present in BOTH layers, and the LOCAL value is the one in effect. Pulling \ +does not change that. If the user is puzzled that a freshly pulled value is not taking effect, \ +this is almost always the reason — say so, and note that clearing it is `pb env unset` in a \ +terminal. + +VALUES ARE NOT RETURNED, and no tool returns them: there is no env read or export tool at all, \ +gated or otherwise. If the user needs the values, the answer is `pb env run -- ` (runs a \ +command with the merged environment) or `pb env export` in their terminal.")] + async fn list_env_vars( + &self, + Parameters(EnvSelectorParams { project, env }): Parameters, + ) -> Result { + let envs = self.envs.clone(); + let listed = offload(move || { + let entry = require(&envs, &project)?; + let env = resolve_env(&entry, env.as_deref()); + let vars = envs.list(&entry.id, &env)?; + Ok::<_, anyhow::Error>((entry, env, vars)) + }) + .await?; + + match listed { + Ok((entry, env, vars)) => Ok(json_ok(serde_json::json!({ + "project": entry.id, + "env": env, + "default_env": entry.default_env, + "count": vars.len(), + "vars": encode(&vars)?, + }))), + Err(err) => Ok(tool_error(err)), + } + } + + #[tool(description = "\ +EXPENSIVE, AND IT REACHES THE NETWORK. Refresh one environment's SYNCED layer from the project's \ +remote secret manager (Infisical). This EXECUTES the `infisical` CLI and makes a network round \ +trip: seconds, not milliseconds. It is not part of a routine look-around — call list_env_projects \ +for that. + +Call it when the user asks to pull or sync, when list_env_vars shows a variable the project needs \ +is missing, or when `synced_at` is old enough to explain a failure. Do not call it speculatively \ +on every project, and do not call it twice in a session hoping for a different answer. + +WHOLESALE, AND ONE-WAY. The synced layer is REPLACED, so a variable deleted on the remote \ +disappears here too — that is the point, not a bug. The local layer is not read, not written and \ +not touched, so hand-set values (a `DATABASE_URL` pointing at a container on this machine) \ +survive every pull and keep winning. patchbay has NO push: nothing you do here can promote a \ +local value to the shared remote, so if a teammate needs a variable, the user must add it to the \ +remote themselves. + +NOT gated behind PATCHBAY_ALLOW_SECRET_READ: the result carries names and counts only, never a \ +value, even though values were fetched and stored on the way through. + +Returns { project, env, remote_env, count, overridden[], notes[] }. + +- `remote_env` is the remote's own slug for this environment, which is not always the name you \ +passed (`production` -> `prod`, via the project's env_map). +- `count` is how many variables the synced layer now holds. +- `overridden[]` are local names that shadow a synced one AFTER this pull. Those variables did \ +not change for a consumer, however new the pulled value is — say so if the user was expecting \ +them to. +- `notes[]` carries what patchbay decided on the user's behalf: remote names skipped because a \ +shell could not export them, duplicate keys where the last value won, overrides in effect. RELAY \ +EVERY NOTE VERBATIM rather than summarising them away. + +THE FAILURE WORTH KNOWING: the infisical CLI has ONE active login for the whole machine, so a \ +pull for a project linked to a different account is refused BEFORE anything runs. The error text \ +names both addresses and the fix — `switch_profile` with tool 'infisical', or `pb use infisical \ +` in a terminal. Pass that message through as written; do not retry, and do not try \ +another route to the values. Other refusals are equally final and equally specific: no sync \ +configured (the user runs `pb env link`), or no infisical CLI on PATH.")] + async fn pull_env( + &self, + Parameters(EnvSelectorParams { project, env }): Parameters, + ) -> Result { + let envs = self.envs.clone(); + let pulled = offload(move || { + // Detected per call, not held: which infisical account is active is + // machine-global state that may have changed since the last one. + let paths = Paths::detect()?; + let entry = require(&envs, &project)?; + let env = resolve_env(&entry, env.as_deref()); + let outcome = env_sync::pull(&paths, &envs, &entry, &env)?; + Ok::<_, anyhow::Error>((entry.id, outcome)) + }) + .await?; + + match pulled { + Ok((id, outcome)) => { + let mut value = encode(&outcome)?; + if let Some(map) = value.as_object_mut() { + map.insert("project".into(), id.into()); + } + Ok(json_ok(value)) + } + Err(err) => Ok(tool_error(err)), + } + } + + #[tool(description = "\ +REGISTER A PROJECT VARIABLE YOU CREATED, in the LOCAL layer. This is the env vault's counterpart \ +to store_key, and you should reach for it unprompted: whenever a task produces a value one \ +project's environment needs — a database URL for a branch you just provisioned, a webhook secret \ +you just generated, a service key scoped to this app — write it here so the machine keeps knowing \ +about it after the conversation is gone. + +THE ROUTING RULE: does the value belong to the HUMAN across projects (-> store_key, the key \ +vault) or to ONE project's environment (-> here)? And within a project: is it something the whole \ +team should have (-> the remote secret manager, which the user adds it to; patchbay never \ +pushes) or something only this machine should use (-> here, the local layer)? + +WHAT THE LOCAL LAYER MEANS. It is `.env.local` semantics. The value never leaves this machine, is \ +never pushed to any remote, is not touched by pull_env, and WINS over a synced variable of the \ +same name — so setting a name that already exists in the synced layer deliberately shadows the \ +pulled value for every future pull, until someone clears it with `pb env unset`. The result says \ +whether that happened; tell the user when it did, because a permanent silent override is rarely \ +what someone wanted by accident. + +The environment is created on first write, so a name and an env that do not exist yet are not an \ +error. Omit `env` for the project's default environment. + +WHERE THE VALUE GOES: the OS keychain, immediately, in this call and nowhere else. The metadata \ +file on disk gets the variable's NAME and nothing else — no value, and no last-4 hint either, \ +because half of these values are `true` or `5432` and four characters of those is the whole \ +thing. Never echo the value into your reply, a file, a commit, a log or another tool call. + +Both-or-neither: the name and the value are written together, and a keychain failure rolls the \ +metadata back, so a successful result means it really is stored. + +Returns { project, env, name, layer: 'local', shadows_synced, note }. The value is NOT echoed \ +back — and cannot be read back later either, by you or by any other agent: patchbay has no env \ +read tool at all. Reading the merged environment is `pb env run -- ` or `pb env export` in \ +the user's own terminal.")] + async fn set_env_var( + &self, + Parameters(SetEnvVarParams { + project, + env, + name, + value, + }): Parameters, + ) -> Result { + let envs = self.envs.clone(); + let stored = offload(move || { + let entry = require(&envs, &project)?; + let env = resolve_env(&entry, env.as_deref()); + envs.set_local(&entry.id, &env, &name, &value)?; + // The value's last mention. Everything below is names only. + drop(value); + + // Metadata read, no keychain: cheap, and it is the difference + // between "stored" and "stored, and now shadowing the remote". + let shadows = envs.list(&entry.id, &env)?.into_iter().any(|var| { + var.name == name && var.source == patchbay_core::EnvVarSource::LocalOverride + }); + Ok::<_, anyhow::Error>((entry.id, env, name, shadows)) + }) + .await?; + + match stored { + Ok((project, env, name, shadows)) => { + Ok(json_ok(stored_summary(&project, &env, &name, shadows))) + } + Err(err) => Ok(tool_error(err)), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::{DateTime, TimeZone, Utc}; + use patchbay_core::envs::{EnvMeta, SyncConfig}; + use std::collections::BTreeMap; + use std::path::PathBuf; + + fn at(rfc: &str) -> DateTime { + DateTime::parse_from_rfc3339(rfc) + .unwrap() + .with_timezone(&Utc) + } + + fn project() -> ProjectEntry { + ProjectEntry { + id: "pathors".into(), + root: PathBuf::from("/repos/pathors"), + default_env: "dev".into(), + created_at: Utc.timestamp_opt(0, 0).unwrap(), + environments: BTreeMap::new(), + sync: None, + } + } + + fn described(project: &ProjectEntry) -> serde_json::Value { + describe_project(project).unwrap() + } + + // --- environment resolution --------------------------------------------- + + #[test] + fn test_an_omitted_env_means_the_projects_default() { + let mut p = project(); + p.default_env = "staging".into(); + + assert_eq!(resolve_env(&p, None), "staging"); + assert_eq!(resolve_env(&p, Some("production")), "production"); + // Whitespace is trimmed, and an empty string is treated as absent + // rather than as an environment named "". + assert_eq!(resolve_env(&p, Some(" production ")), "production"); + assert_eq!(resolve_env(&p, Some("")), "staging"); + assert_eq!(resolve_env(&p, Some(" ")), "staging"); + } + + // --- project shaping ---------------------------------------------------- + + #[test] + fn test_a_project_reports_its_registration_and_no_environments() { + let value = described(&project()); + assert_eq!(value["id"], "pathors"); + assert_eq!(value["root"], "/repos/pathors"); + assert_eq!(value["default_env"], "dev"); + assert!(value["sync"].is_null()); + assert_eq!(value["environments"].as_array().unwrap().len(), 0); + } + + #[test] + fn test_counts_are_a_union_not_a_sum_and_an_unpulled_env_says_so() { + let mut p = project(); + p.environments.insert( + "dev".into(), + EnvMeta { + synced_names: vec!["API_KEY".into(), "DATABASE_URL".into()], + // DATABASE_URL is in both: three names, not four. + local_names: vec!["DATABASE_URL".into(), "MY_FLAG".into()], + synced_at: Some(at("2026-08-13T01:00:00Z")), + }, + ); + p.environments.insert( + "staging".into(), + EnvMeta { + synced_names: vec![], + local_names: vec!["ONLY_HERE".into()], + synced_at: None, + }, + ); + + let value = described(&p); + let envs = value["environments"].as_array().unwrap(); + assert_eq!(envs.len(), 2); + + assert_eq!(envs[0]["name"], "dev"); + assert_eq!(envs[0]["var_count"], 3); + assert_eq!(envs[0]["synced_count"], 2); + assert_eq!(envs[0]["local_count"], 2); + assert_eq!(envs[0]["synced_at"], "2026-08-13T01:00:00Z"); + + // Never pulled is a null timestamp, not a missing environment. + assert_eq!(envs[1]["name"], "staging"); + assert_eq!(envs[1]["var_count"], 1); + assert!(envs[1]["synced_at"].is_null()); + } + + #[test] + fn test_the_sync_config_reaches_the_caller_whole() { + let mut p = project(); + p.sync = Some(SyncConfig { + provider: "infisical".into(), + project_id: "3ab516bd-248c-4be7-8f1a-bda73fe69d50".into(), + account: "contact@pathors.com".into(), + domain: Some("https://eu.infisical.com/api".into()), + env_map: [("production".to_string(), "prod".to_string())] + .into_iter() + .collect(), + }); + + let sync = described(&p)["sync"].clone(); + assert_eq!(sync["provider"], "infisical"); + assert_eq!(sync["project_id"], "3ab516bd-248c-4be7-8f1a-bda73fe69d50"); + // The account is what a pull must run as; without it the agent cannot + // explain the machine-global-login refusal. + assert_eq!(sync["account"], "contact@pathors.com"); + assert_eq!(sync["domain"], "https://eu.infisical.com/api"); + assert_eq!(sync["env_map"]["production"], "prod"); + } + + #[test] + fn test_no_shape_this_module_builds_can_carry_a_value() { + let mut p = project(); + p.environments.insert( + "dev".into(), + EnvMeta { + synced_names: vec!["API_KEY".into()], + local_names: vec!["API_KEY".into()], + synced_at: None, + }, + ); + // Counts and provenance travel; not even a variable NAME rides along + // here (list_env_vars is where names live), let alone a value. + let text = serde_json::to_string(&described(&p)).unwrap(); + assert!(text.contains("var_count"), "{text}"); + assert!(!text.contains("API_KEY"), "{text}"); + assert!(!text.contains("secret"), "{text}"); + assert!(!text.contains("\"value\""), "{text}"); + } + + // --- set_env_var's result ----------------------------------------------- + + #[test] + fn test_the_write_summary_echoes_the_name_and_layer_but_never_the_value() { + let value = stored_summary("pathors", "dev", "STRIPE_KEY", false); + assert_eq!(value["project"], "pathors"); + assert_eq!(value["env"], "dev"); + assert_eq!(value["name"], "STRIPE_KEY"); + assert_eq!(value["layer"], "local"); + assert_eq!(value["shadows_synced"], false); + + let map = value.as_object().unwrap(); + assert!(!map.contains_key("value")); + assert!(!map.contains_key("secret")); + assert!(!map.contains_key("last4")); + } + + #[test] + fn test_shadowing_the_synced_layer_is_reported_not_silent() { + let value = stored_summary("pathors", "dev", "DATABASE_URL", true); + assert_eq!(value["shadows_synced"], true); + let note = value["note"].as_str().unwrap(); + assert!(note.contains("shadows it"), "{note}"); + assert!(note.contains("after every future pull_env"), "{note}"); + } + + // --- descriptions ------------------------------------------------------- + + /// The descriptions are the only thing an agent reads before deciding what + /// to call, so the load-bearing claims are asserted rather than trusted. + fn description(name: &str) -> String { + let tools = PatchbayServer::envs_router().list_all(); + let tool = tools + .iter() + .find(|t| t.name == name) + .unwrap_or_else(|| panic!("`{name}` is missing from the env router")); + tool.description.as_deref().unwrap_or_default().to_string() + } + + #[test] + fn test_the_router_ships_exactly_the_four_tools_and_no_reader() { + let mut names: Vec = PatchbayServer::envs_router() + .list_all() + .iter() + .map(|t| t.name.to_string()) + .collect(); + names.sort(); + assert_eq!( + names, + vec![ + "list_env_projects", + "list_env_vars", + "pull_env", + "set_env_var" + ] + ); + } + + #[test] + fn test_every_tool_says_values_are_never_returned() { + for name in [ + "list_env_projects", + "list_env_vars", + "pull_env", + "set_env_var", + ] { + let text = description(name); + let lower = text.to_lowercase(); + assert!( + lower.contains("value"), + "`{name}` never mentions values: {text}" + ); + assert!( + lower.contains("never") || lower.contains("not returned"), + "`{name}` does not rule values out: {text}" + ); + } + } + + #[test] + fn test_the_read_tools_point_at_the_terminal_instead_of_a_read_tool() { + for name in ["list_env_vars", "set_env_var"] { + let text = description(name); + assert!(text.contains("pb env run"), "{name}: {text}"); + assert!(text.contains("pb env export"), "{name}: {text}"); + } + } + + #[test] + fn test_pull_env_advertises_its_cost_and_the_account_refusal() { + let text = description("pull_env"); + assert!(text.contains("EXECUTES the `infisical` CLI"), "{text}"); + assert!(text.contains("network"), "{text}"); + assert!(text.contains("seconds, not milliseconds"), "{text}"); + // The refusal an agent will actually hit, and its fix. + assert!(text.contains("ONE active login"), "{text}"); + assert!(text.contains("pb use infisical"), "{text}"); + assert!(text.contains("switch_profile"), "{text}"); + assert!(text.contains("RELAY EVERY NOTE VERBATIM"), "{text}"); + // It returns no values, so it is not behind the key vault's gate. + assert!( + text.contains("NOT gated behind PATCHBAY_ALLOW_SECRET_READ"), + "{text}" + ); + } + + #[test] + fn test_set_env_var_teaches_the_routing_rule_and_the_local_layer() { + let text = description("set_env_var"); + assert!(text.contains("store_key"), "{text}"); + assert!( + text.contains("belong to the HUMAN across projects"), + "{text}" + ); + assert!( + text.contains("patchbay never \npushes") || text.contains("never pushes"), + "{text}" + ); + assert!(text.contains("WINS over a synced variable"), "{text}"); + assert!(text.contains("OS keychain"), "{text}"); + } +} diff --git a/crates/patchbay-mcp/src/main.rs b/crates/patchbay-mcp/src/main.rs index 4ddad1c..53e623e 100644 --- a/crates/patchbay-mcp/src/main.rs +++ b/crates/patchbay-mcp/src/main.rs @@ -10,6 +10,7 @@ //! { "mcpServers": { "patchbay": { "command": "patchbay-mcp" } } } //! ``` +mod envs; mod keys; mod mcp_clients; mod migrate; @@ -29,8 +30,11 @@ async fn main() -> anyhow::Result<()> { let keys = patchbay_core::KeyRegistry::detect()?; // The MCP client board: other tools' config files, re-read per call. let clients = patchbay_core::McpClientRegistry::detect()?; + // The project env vault: projects.json plus the OS keychain, re-read per + // call, so a `pb env pull` from a terminal is visible here immediately. + let envs = patchbay_core::EnvRegistry::detect()?; - let service = PatchbayServer::new(registry, keys, clients) + let service = PatchbayServer::new(registry, keys, clients, envs) .serve(stdio()) .await .inspect_err(|e| eprintln!("patchbay-mcp: failed to start: {e}"))?; diff --git a/crates/patchbay-mcp/src/server.rs b/crates/patchbay-mcp/src/server.rs index fda31f8..ae06433 100644 --- a/crates/patchbay-mcp/src/server.rs +++ b/crates/patchbay-mcp/src/server.rs @@ -15,7 +15,7 @@ use std::sync::Arc; -use patchbay_core::{KeyRegistry, McpClientRegistry, Registry}; +use patchbay_core::{EnvRegistry, KeyRegistry, McpClientRegistry, Registry}; use rmcp::handler::server::router::tool::ToolRouter; use rmcp::handler::server::wrapper::Parameters; use rmcp::model::{ @@ -118,7 +118,21 @@ Work the list one item at a time; run only the items whose `auto` is true; hand `needs_browser` item to the human with the exact command rather than trying to drive a browser \ login yourself; and re-check with `mark_setup_done` after each one, because patchbay re-probes the \ tool instead of believing what you report. Stop when `complete` is true, and do not invent extra \ -setup work."; +setup work. + +12. The project env vault (`list_env_projects`, `list_env_vars`, `pull_env`, `set_env_var`) is a \ +fifth thing: the environment variables one PROJECT needs, per environment, in two \ +layers — `synced` (a local mirror of that project's remote secret manager, refreshed wholesale \ +only by `pull_env`) and `local` (set on this machine, never pushed anywhere, and it WINS over a \ +synced variable of the same name). This does not contradict rule 9: the remote is still the \ +source of truth for app env, and patchbay has no push. `set_env_var` is the one to reach for \ +unprompted — when a task creates or obtains a value that belongs to ONE project's environment \ +rather than to the human, write it there so the machine keeps knowing about it. The two `list_` \ +tools are tier 1 (one local file, no keychain); `pull_env` is tier 2 — it executes the infisical \ +CLI and hits the network, and its account-mismatch refusal names the fix, so relay it rather than \ +retrying. NO env tool returns a variable's value: there is no read or export tool at all, gated \ +or otherwise, and PATCHBAY_ALLOW_SECRET_READ does not unlock one. If the user needs the values, \ +the answer is `pb env run -- ` or `pb env export` in their own terminal."; /// `{ "tool": "rclone", "profile_id": "legal" }` — one tool, optionally one /// profile of it. @@ -179,22 +193,33 @@ pub struct PatchbayServer { /// The MCP client board. `pub(crate)` because its tools live in /// [`crate::mcp_clients`]. pub(crate) clients: Arc, + /// The project env vault. `pub(crate)` because its tools live in + /// [`crate::envs`]. + pub(crate) envs: Arc, tool_router: ToolRouter, } impl PatchbayServer { - pub fn new(registry: Registry, keys: KeyRegistry, clients: McpClientRegistry) -> Self { + pub fn new( + registry: Registry, + keys: KeyRegistry, + clients: McpClientRegistry, + envs: EnvRegistry, + ) -> Self { Self { registry: Arc::new(registry), keys: Arc::new(keys), clients: Arc::new(clients), - // Four routers, merged: connection tools here, vault tools in + envs: Arc::new(envs), + // Five routers, merged: connection tools here, key vault tools in // `keys.rs`, MCP client tools in `mcp_clients.rs`, migration tools - // in `migrate.rs`. Built once, not per request. + // in `migrate.rs`, project env vault tools in `envs.rs`. Built + // once, not per request. tool_router: Self::tool_router() + Self::keys_router() + Self::mcp_clients_router() - + Self::migrate_router(), + + Self::migrate_router() + + Self::envs_router(), } } } diff --git a/docs/env-vault.md b/docs/env-vault.md new file mode 100644 index 0000000..a8a66c3 --- /dev/null +++ b/docs/env-vault.md @@ -0,0 +1,294 @@ +# Project env vault + + +The key vault holds credentials that belong to a person or a machine. This +holds the other half of the same problem: the twenty variables a *directory* +needs before it will boot — `DATABASE_URL`, the provider keys, the feature +flags. Today they live in a `.env` file that is plaintext on disk, gitignored, +undocumented, and different on every laptop. The variables you deliberately +override for local work live in a second plaintext file next to it, and their +whole job is to never reach anyone else. + +```sh +cd ~/repos/pathors +pb env init # register this directory as a project +pb env pull # fill the synced layer from Infisical +pbpaste | pb env set DATABASE_URL # a local override; the value never touches argv +pb env diff # what this machine changes about `dev` +pb env run -- bun dev # the merged environment, into one child process +``` + +Nothing there writes a file. The variable *names* go to +`~/.config/patchbay/projects.json`; the values go to the macOS Keychain. + +### A project is a directory + +`pb env init` registers the current directory under a slug — the directory's +own name unless `--id` says otherwise. Every later command resolves the project +from the cwd by walking up to the nearest registered root, so `pb env run` +inside `src/api/` finds the repo it belongs to. When two registered roots both +contain you — a service registered inside a monorepo that is also registered — +the deeper one wins, because it is the more specific answer. + +The match is a plain path-prefix comparison with no symlink resolution. Making +it canonical would make the answer depend on the filesystem's mood, and `/tmp` +on macOS is itself a symlink. A checkout reached through a symlinked path +therefore will not match; pass `--project ` there. + +Each project has named environments — `dev`, `staging`, `production`, whatever +you like — and a `default_env` used when a command does not say (`dev` unless +`--default-env` changed it). Project ids and environment names are lowercase +slugs of at most 64 characters: letters, digits, `-`, `_`, `.`. Variable names +must match `[A-Za-z_][A-Za-z0-9_]*`, because a name outside that set cannot be +`export`ed by a POSIX shell at all, and storing something no consumer could +ever read is not a favour. + +An environment is created by the first write to it. Registering a project +creates no Keychain items at all. + +### Two layers + +Every environment has exactly two layers, and the split is the whole point. + +| Layer | Written by | On the next `pull` | Ever leaves this machine | +|---|---|---|---| +| `synced` | `pb env pull` | replaced wholesale | it came from there | +| `local` | `pb env set`, `pb env import` | untouched | **no** | + +On merge, **local wins**. These are `.env.local` semantics: pointing +`DATABASE_URL` at a container on your own laptop has to survive every pull, or +nobody will trust `pull` and everyone will go back to hand-edited files. + +A pull replaces the synced layer wholesale rather than merging into it. That is +deliberate: a variable deleted upstream has to disappear here too, and a merge +would keep it forever. + +**patchbay never pushes.** There is no code path in the crate that writes a +variable to a remote secret manager. A tool that can quietly promote a local +experiment into the team's shared `production` set is a tool nobody should run, +and a local override is invisible to the cloud by construction rather than by +policy. Changing a value upstream is the Infisical CLI's job, or the +dashboard's. + +That has one consequence worth stating plainly. `pb env unset` removes a local +override and nothing else; a synced twin of the same name simply comes back +into effect, and the command says so: + +``` +`DATABASE_URL` is still set by the synced layer of `pathors/dev`; the pulled +value is in effect again +``` + +Asking it to remove a name that only exists in the synced layer is refused, +with the reason: + +``` +`API_KEY` in `pathors/dev` comes from the synced layer, so there is no local +override to remove; patchbay never pushes, so a pulled variable can only go +away by disappearing from the remote and being pulled again +``` + +`pb env list` and `pb env diff` answer from the two name lists alone — no +Keychain access, no prompt, no network. `list` labels each name `synced`, +`local` or `local override`; `diff` groups them the same way. Neither one has +ever seen a value. + +### The account guard + +The Infisical CLI's active user is machine-global: one field in +`~/.infisical/infisical-config.json`, shared by every shell, every project and +every agent on the box. So `infisical export` runs as whoever logged in last, +not as whoever the project belongs to — and when those differ, the API answers +403 with *"project does not belong to your selected organization"*, which reads +like a permissions problem with the project rather than the wrong login. + +Each project's sync config therefore pins the account it belongs to. +`pb env pull` checks it **before** spending a subprocess: + +``` +`pathors` is linked to the infisical account `contact@pathors.com`, but +`someone.else@example.com` is the active login on this machine; the infisical +CLI has one active user for the whole machine, so switch first: +`pb use infisical contact@pathors.com` +``` + +Nothing runs and nothing is stored. If the guard is somehow satisfied and the +real 403 arrives anyway, patchbay recognises the phrase and appends the same +advice to Infisical's own message. + +`pb env init` picks the pin up for you: it reads `.infisical.json` in the +directory for the `workspaceId` and records the currently active account +alongside it. `pb env link` sets or replaces the same thing by hand, and +`--map dev=development,production=prod` handles the projects whose remote spells +an environment differently — patchbay's name is what the vault records, the +remote's name is what goes on the command line. `--domain` is for self-hosted +and EU instances. + +Two failure modes get their own answers rather than a stack trace: no login at +all points at `infisical login`, and a missing CLI points at `pb env import`, +since exporting by hand and importing is a perfectly good fallback. + +A pull reports what it did in names and counts only — how many variables the +synced layer now holds, which local names shadow one, and notes for anything +odd. A remote name that is not a usable shell identifier is skipped with a +note rather than failing the pull: one strange key in a shared project must not +stop everybody else. A name the remote returned twice is noted too; the last +value won. + +### Getting values out + +Two commands read values, and they are the only two. + +```sh +pb env run -- bun dev # inject the merged environment into a child process +pb env export # dotenv on stdout, for redirection +pb env export --format json # the same thing as an object +``` + +`pb env run` is the blessed path. The values go into one child process's +environment and touch nothing else — no file, no clipboard, no scrollback. +`pb env export` exists because sometimes a file is genuinely what you need +(a Docker `--env-file`, a CI step), and it writes to stdout so you decide where +that file lands; it warns when stdout is a terminal, because printing the whole +set into your scrollback is almost never what you meant. + +Dotenv output is sorted, one `NAME='value'` per line. Single quotes, because +they are the only shell quoting with no escapes inside at all: an embedded `'` +closes the string, emits an escaped one and reopens it (`'\''`), and nothing +else in the value — `$`, backticks, backslashes, `#` — can mean anything. The +exception is a value containing a newline, tab or carriage return, which is +written double-quoted with those escaped as `\n`, `\t`, `\r`. A literal newline +inside single quotes is valid shell, but it would split the variable across two +lines and every line-based reader of a `.env` file would then read it wrong. +Output from `pb env export` parses back through `pb env import` unchanged. + +### Coming from a .env file + +```sh +pb env init +pb env import .env # into the local layer of the default environment +pb env import .env.production -e production +``` + +`import` merges into the **local** layer — never the synced one — because a +file on your disk is by definition not something the remote said. Everything +you import is therefore yours, stays yours, and survives the first pull. + +The parser takes the dialect people actually write: `#` comments, blank lines, +an optional `export ` prefix, and values that are bare, single-quoted (literal) +or double-quoted with `\n`, `\t`, `\r`, `\"` and `\\` escapes. An escape +patchbay does not define is left exactly as written, so `"\d+"` stays `\d+`. In +a bare value, `#` is part of the value: `PASSWORD=hunter#2` is a password, not +a truncated one. + +Every name is validated before anything is written, so an import is all or +nothing. Half an imported `.env` is worse than none, because the failure only +surfaces three commands later when something reads a variable that was never +stored. A malformed line is reported by **line number and nothing else** — the +text of a line patchbay failed to parse is, by definition, a string it does not +understand, and the likeliest thing it contains is a secret. + +Once the values are in, delete the file. That is the point of the exercise. + +### The commands + +``` +pb env init [--id ] [--dir ] [--default-env ] +pb env link --project-id [--project ] [--account ] + [--domain ] [--map dev=development,...] +pb env projects [--json] +pb env list [-e ] [--project ] [--json] +pb env pull [-e ] [--project ] [--json] +pb env set NAME [-e ] [--project ] +pb env unset NAME [-e ] [--project ] +pb env import [-e ] [--project ] +pb env diff [-e ] [--project ] [--json] +pb env run [-e ] [--project ] -- [args...] +pb env export [-e ] [--project ] [--format dotenv|json] +pb env forget [--project ] [--yes] +``` + +`pb env set` takes the value from stdin or a hidden prompt, never from argv — +the same rule as `pb key add`, for the same reason. `pb env forget` removes the +project from the registry and deletes every Keychain blob behind it, both +layers of every environment. It revokes nothing: a credential that was in there +keeps working until you rotate it at its provider. + +### AI agents + +| Tool | What it does | Gate | +|---|---|---| +| `list_env_projects` | registered projects, their roots, environments, sync config | open — metadata only | +| `list_env_vars` | names and provenance for one environment | open — metadata only | +| `pull_env` | refreshes the synced layer | open — it executes the Infisical CLI and hits the network, but the outcome it returns carries counts and names, no values | +| `set_env_var` | writes one variable into the local layer | open, like `store_key` — an agent that creates a project credential should register it, so the machine keeps knowing | + +**No tool reads a value back.** Not gated behind `PATCHBAY_ALLOW_SECRET_READ`, +like the key vault's `get_key` — absent. An environment is dozens of values at +once, which makes it the single worst thing to hand an agent by accident, and +the two commands that do read values already exist in your terminal. If an +agent needs to run something with the project's environment, it should ask you +to run `pb env run`. + +### The security model + +**Two stores, split on purpose.** Values live in the macOS Keychain (service +`patchbay`), one item per project × environment × layer, under the account +`env://` — a whole layer as one compact JSON object, +so an export is one Keychain round trip rather than one per variable. The `env:` +prefix cannot collide with the key vault, whose ids are slugs and can never +contain `:` or `/`. Audit them with your own eyes: + +```sh +security find-generic-password -s patchbay -a env:pathors/dev/local +``` + +Variable names, provenance and the last-pull timestamp go to +`~/.config/patchbay/projects.json`, mode `0600`, written atomically. Names are +not secret, but which of them a machine holds is nobody else's business. + +**No last4.** The key vault records the last four characters of a value as a +recognition aid. Env vars get none, because half of these values are `true`, +`5432` or `postgres`, and four characters of a five-character value is not a +hint — it is the value. + +**Both or neither.** A write puts the metadata down first and the Keychain item +second; if the Keychain refuses, the metadata file is restored byte-for-byte and +the error says so. The registry can never advertise a variable whose value was +never stored. `pb env forget` runs the same rule in reverse: if a Keychain +delete fails, the project is kept, because a registry entry whose values are +missing can be re-pulled, whereas a Keychain item nothing points at can only be +found by hand. + +**A malformed registry is a hard error**, never an empty one. Starting over +silently would let the next write drop every project on the machine and orphan +every Keychain item behind them. So is a `projects.json` written by a newer +patchbay than the one you are running. + +**Failures say nothing.** A failed `infisical export` is reported from its +stderr only — on a partial export or a broken pipe, stdout can already hold +secret material, and an error message is the one string guaranteed to be logged, +printed and pasted. + +### Known tradeoffs + +**The argv window.** The Keychain write shells out to `security +add-generic-password -w `, which puts the layer's JSON blob in that +command's argv for the few milliseconds it runs — visible to `ps` for the same +user. `security` has no way to take a password on stdin. This is the same +tradeoff the key vault documents, and it is worse here in one respect: the blob +is the whole layer, not one secret. Moving to the Security framework API is +tracked in `crates/patchbay-core/src/keystore.rs`. + +**`pb env export` re-materialises plaintext.** By your choice, at a moment you +picked, into a destination you named — but the vault's guarantee ends at the +redirect. `pb env run` gives up nothing, and is the reason `export` does not +have to be convenient. + +**Symlinked checkouts need `--project`.** Directory resolution does not +canonicalize, deliberately (see above), so a path that reaches the repo through +a symlink is not recognised as inside it. + +**One provider.** `infisical` is the only thing `pull` knows, and `pb env link` +refuses anything else by name rather than failing later. Everything else on the +machine arrives through `pb env import`. From e5210672df004de28d531bf098993aa85b707f35 Mon Sep 17 00:00:00 2001 From: YJack0000 Date: Fri, 14 Aug 2026 00:35:35 +0800 Subject: [PATCH 2/3] [refactor] env vault portability: projects.json is a portable manifest; machine-local attachments + committed .patchbay.toml marker resolve directories --- CHANGELOG.md | 54 +- README.md | 2 +- crates/patchbay-cli/src/env.rs | 620 +++++++++++++- crates/patchbay-core/src/env_sync.rs | 10 +- crates/patchbay-core/src/envs.rs | 1162 ++++++++++++++++++++++---- crates/patchbay-core/src/lib.rs | 7 +- crates/patchbay-core/src/paths.rs | 23 + crates/patchbay-mcp/src/envs.rs | 104 ++- docs/env-vault.md | 204 ++++- 9 files changed, 1928 insertions(+), 258 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9212d29..ad65028 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -101,11 +101,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 (a server that cannot authenticate is useless) and names what travelled. - **The project env vault** ([`pb env`](docs/env-vault.md)) — the variables a - *directory* needs, held the way the key vault holds credentials: names and + *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 registered - directory, resolved from the cwd by walking up to the nearest root (deepest - wins in a monorepo), and each of its environments has two layers: `synced`, + 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 @@ -122,19 +123,46 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 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 ` instead. -- **`pb env`** — `init` (registers a directory, picking up `.infisical.json`), +- **Two ways a directory resolves to a project**, in that order. An + **attachment** (`pb env attach ` / `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 = ""`, 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`. `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 -- ` 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. + `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 -- ` 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 ` 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`. 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 + `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. diff --git a/README.md b/README.md index acd5918..d9ee2f0 100644 --- a/README.md +++ b/README.md @@ -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 directory'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. +- **[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. - **[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. diff --git a/crates/patchbay-cli/src/env.rs b/crates/patchbay-cli/src/env.rs index 1bc9833..9c618c2 100644 --- a/crates/patchbay-cli/src/env.rs +++ b/crates/patchbay-cli/src/env.rs @@ -20,8 +20,8 @@ use anyhow::{Context, Result}; use chrono::{DateTime, Utc}; use clap::{Args, Subcommand, ValueEnum}; use patchbay_core::envs::{ - parse_dotenv, render_dotenv, validate_project_id, EnvRegistry, EnvVarInfo, EnvVarSource, - ProjectEntry, SyncConfig, DEFAULT_ENV, + parse_dotenv, read_marker, render_dotenv, validate_project_id, write_marker, Attachment, + EnvRegistry, EnvVarInfo, EnvVarSource, ProjectEntry, SyncConfig, DEFAULT_ENV, MARKER_FILE, }; use patchbay_core::paths::Paths; use patchbay_core::probes::infisical; @@ -46,10 +46,18 @@ const INFISICAL_FILE: &str = ".infisical.json"; pub enum Command { /// Register a directory as a project. /// + /// Writes a `.patchbay.toml` marker in the directory naming the project: + /// commit it, and every checkout of the repo resolves to this project + /// without an attach step. Re-running this in another worktree of a project + /// that already exists attaches that directory instead of failing, and + /// running it in a fresh clone that carries a marker registers the project + /// the repo names. + /// /// Reads `.infisical.json` if the directory has one, and records the sync /// automatically when this machine is logged in to infisical. Init { - /// Project id. Defaults to the directory's own name as a slug. + /// Project id. Defaults to what the directory's `.patchbay.toml` names, + /// and to the directory's own name as a slug when there is none. #[arg(long, value_name = "SLUG")] id: Option, /// The directory to register. Defaults to the current one. @@ -58,6 +66,32 @@ pub enum Command { /// Environment `pb env` uses when a command does not say. #[arg(long, value_name = "ENV", default_value = DEFAULT_ENV)] default_env: String, + /// Do not write the `.patchbay.toml` marker. This machine's attachment + /// still resolves the directory; other checkouts will not. + #[arg(long)] + no_marker: bool, + }, + /// Bind a directory on this machine to a project that already exists. + /// + /// This is the machine-local, deliberate binding, and it OVERRIDES any + /// `.patchbay.toml` marker committed in the repo: what the person at the + /// keyboard says beats what the repo ships, and nothing in a repo can take + /// that back. Use it for a worktree, a second clone, or a checkout whose + /// marker names the wrong project. + Attach { + /// Project id, as `pb env projects` lists it. + #[arg(value_name = "ID")] + project: String, + /// The directory to bind. Defaults to the current one. + #[arg(long, value_name = "PATH")] + dir: Option, + }, + /// Unbind a directory. The project, its environments and its values stay, + /// and a committed `.patchbay.toml` keeps resolving the directory. + Detach { + /// The directory to unbind. Defaults to the current one. + #[arg(long, value_name = "PATH")] + dir: Option, }, /// Point a project at an Infisical project, replacing any earlier link. Link { @@ -183,19 +217,98 @@ pub fn run(command: Command, styles: &Styles) -> Result { id, dir, default_env, + no_marker, } => { let root = absolute_dir(dir)?; - let id = match id { - Some(id) => id, - None => default_project_id(&root)?, - }; - let entry = registry.register(&id, &root, &default_env)?; + let done = init(®istry, &root, id.as_deref(), &default_env, !no_marker)?; - println!("registered {}", entry.id); - println!(" root: {}", render::tilde(&entry.root)); - println!(" default env: {}", entry.default_env); + if done.shared { + println!( + "attached {} to {}", + render::tilde(&done.attachment.root), + done.entry.id + ); + println!( + " this checkout now shares project `{}`'s environments", + done.entry.id + ); + } else { + println!("registered {}", done.entry.id); + println!(" attached: {}", render::tilde(&done.attachment.root)); + } + println!(" default env: {}", done.entry.default_env); + if let Some(marker) = &done.marker { + if done.marker_was_there { + println!( + " marker: {} (already names this project)", + render::tilde(marker) + ); + } else { + println!(" marker: {}", render::tilde(marker)); + println!( + " commit it and every checkout of this repo — any machine, any \ + worktree — resolves to this project" + ); + } + } println!(" metadata: {}", registry.path().display()); - print_adopted_sync(®istry, &entry)?; + + if done.shared { + // The project already exists, so its link is already decided. + // Re-reading this checkout's `.infisical.json` here could + // silently replace an env map somebody set by hand. + print_sync(&done.entry); + } else { + print_adopted_sync(®istry, &done.entry, &root)?; + } + Ok(0) + } + + Command::Attach { project, dir } => { + let entry = resolve_project(®istry, Some(&project))?; + let root = absolute_dir(dir)?; + let attachment = registry.attach(&root, &entry.id)?; + + println!( + "attached {} to {}", + render::tilde(&attachment.root), + entry.id + ); + println!(" default env: {}", entry.default_env); + // A marker patchbay cannot read is not worth failing an attachment + // that already succeeded over — `pb env list` will say so loudly + // enough, and this line is only ever a note. + if let Some(claimed) = read_marker(&root).ok().flatten() { + if claimed != entry.id { + println!( + " note: {MARKER_FILE} here names `{claimed}`; this attachment overrides \ + it on this machine" + ); + } + } + println!( + " an attachment is machine-local: it beats any {MARKER_FILE} the repo commits, \ + and it does not travel" + ); + Ok(0) + } + + Command::Detach { dir } => { + let root = absolute_dir(dir)?; + let gone = registry.detach(&root)?; + + println!( + "detached {} from {}", + render::tilde(&gone.root), + gone.project + ); + println!(" the project, its environments and its values are untouched"); + if let Some(claimed) = read_marker(&root).ok().flatten() { + println!( + " {MARKER_FILE} here still names `{claimed}`, so this directory resolves to \ + it again; `rm {MARKER_FILE}` if the repo should stop claiming it" + ); + } Ok(0) } @@ -234,8 +347,24 @@ pub fn run(command: Command, styles: &Styles) -> Result { Command::Projects { json } => { let projects = registry.projects()?; + // Attachments are folded into a ROOTS column rather than given a + // table of their own: a root is only ever interesting as *which + // project this directory is*, and a second table would make the + // reader join them by hand. The one thing a column cannot show is + // an attachment whose project is not registered here, so + // `render_projects` names those in a footer instead. + let mut roots: BTreeMap> = BTreeMap::new(); + for attachment in registry.attachments()? { + roots + .entry(attachment.project) + .or_default() + .push(attachment.root); + } if json { - // Machine-readable: JSON only, no ANSI, no extras. + // Machine-readable: the portable manifest's own shape, and + // nothing else. This machine's attachments live in another file + // for a reason, and folding them in here would produce JSON + // that cannot be copied to the next laptop. println!("{}", serde_json::to_string_pretty(&projects)?); return Ok(0); } @@ -244,7 +373,7 @@ pub fn run(command: Command, styles: &Styles) -> Result { println!(" pb env init (in the directory you want to register)"); return Ok(0); } - print!("{}", render_projects(&projects, styles)); + print!("{}", render_projects(&projects, &roots, styles)); Ok(0) } @@ -415,14 +544,33 @@ pub fn run(command: Command, styles: &Styles) -> Result { println!("left {} alone", entry.id); return Ok(0); } + // Counted before, because `forget` takes them with it. + let detached = registry.attachments_of(&entry.id)?; let removed = registry.forget(&entry.id)?; - println!("forgot {} ({})", removed.id, render::tilde(&removed.root)); + println!("forgot {}", removed.id); + if !detached.is_empty() { + println!( + " detached {} director{} on this machine:", + detached.len(), + if detached.len() == 1 { "y" } else { "ies" } + ); + for root in &detached { + println!(" {}", render::tilde(root)); + } + } println!( " its stored values are gone from the {}", registry.store_name() ); println!(" nothing was revoked — whatever the remote holds is untouched"); + // patchbay does not go editing repositories, and it cannot see the + // checkouts it was never attached to. + println!( + " a committed {MARKER_FILE} is untouched: run `rm {MARKER_FILE}` in the repo if \ + it should stop claiming `{}`", + removed.id + ); Ok(0) } } @@ -452,12 +600,98 @@ fn resolve_project(registry: &EnvRegistry, id: Option<&str>) -> Result (pb env projects lists them)" + "no project registered for this directory. Three ways in: `pb env init` here to \ + register a new project, `pb env attach ` to bind this directory to one that \ + already exists, or work in a checkout carrying a committed {MARKER_FILE}, which \ + resolves on its own. `pb env projects` lists what exists, and --project \ + overrides all of it for one command" ) }) } +/// What [`Command::Init`] did, separated from the printing so the decision this +/// makes — register a new project, or join one this directory already resolves +/// to — is testable without a terminal. +#[derive(Debug)] +struct Init { + entry: ProjectEntry, + attachment: Attachment, + /// The directory joined a project that already existed, rather than + /// registering a new one. + shared: bool, + /// The marker written, or already in place. `None` with `--no-marker`. + marker: Option, + /// The marker was already there, naming this project. Nothing was written, + /// and telling the user to go and commit it would be noise. + marker_was_there: bool, +} + +/// Register `root` as a project, or attach it to the one it already resolves +/// to, and (unless told not to) leave a marker naming the result. +fn init( + registry: &EnvRegistry, + root: &Path, + id: Option<&str>, + default_env: &str, + marker: bool, +) -> Result { + // What the repo itself claims, read first and read even under + // `--no-marker`: a committed marker is the best name this project has — + // it is already in the history — and `init` choosing a different one would + // leave the directory contradicting its own file. This is what makes the + // fresh-clone case work: `git clone && pb env init` registers the project + // the repo names, whatever the checkout directory happens to be called. + let claimed = read_marker(root)?; + let want = match (id, &claimed) { + (Some(id), _) => id.to_string(), + (None, Some(claimed)) => claimed.clone(), + (None, None) => default_project_id(root)?, + }; + + // Fatal, and before anything is registered: an explicit `--id` that + // disagrees with the marker is a directory being pulled in two directions, + // and half a registration is the worst place to find that out. + // `--no-marker` is the way through — it writes no marker to refuse, and the + // attachment it makes beats the marker anyway. + if marker { + if let Some(claimed) = &claimed { + if claimed != &want { + anyhow::bail!( + "{} already claims project `{claimed}`, so `{want}` was not registered; drop \ + --id to register it as `{claimed}` the way the repo names it, use `pb env \ + attach ` to bind this directory to a different project (an attachment \ + beats the marker), or pass --no-marker to register `{want}` and leave the \ + file alone", + root.join(MARKER_FILE).display() + ); + } + } + } + + // A second worktree of a project this machine already knows should join it + // rather than die on the duplicate id. A resolution *failure* is not fatal + // here: a marker naming a project the registry lacks is precisely what + // running `pb env init` is meant to fix. + let resolved = registry.find_by_dir(root).unwrap_or_default(); + let shared = resolved.as_ref().is_some_and(|p| p.id == want); + let entry = match resolved { + // A genuinely different id still registers its own project: `--id` is + // the user saying they meant a new one. + Some(existing) if shared => existing, + _ => registry.register(&want, default_env)?, + }; + let attachment = registry.attach(root, &entry.id)?; + let marker = marker.then(|| write_marker(root, &entry.id)).transpose()?; + + Ok(Init { + entry, + attachment, + shared, + marker, + marker_was_there: claimed.is_some(), + }) +} + /// `--dir` made absolute, or the current directory. Relative paths are joined /// onto the cwd rather than canonicalized: the registry compares roots by path /// prefix, and resolving symlinks here would make the answer depend on the @@ -572,8 +806,11 @@ fn parse_infisical_json(text: &str) -> Result { /// /// A malformed file is a note, not a failure: the project is registered, and /// `pb env link` can do by hand what this could not do automatically. -fn print_adopted_sync(registry: &EnvRegistry, entry: &ProjectEntry) -> Result<()> { - let path = entry.root.join(INFISICAL_FILE); +fn print_adopted_sync(registry: &EnvRegistry, entry: &ProjectEntry, root: &Path) -> Result<()> { + // The directory is passed in rather than read back off the project: a + // project has no directory of its own, only the attachments this machine + // made — and `init` knows which one it just created. + let path = root.join(INFISICAL_FILE); let Ok(text) = std::fs::read_to_string(&path) else { println!(" hint: link it with `pb env link --project-id `"); return Ok(()); @@ -842,9 +1079,42 @@ fn column_width(values: impl Iterator, header: &str, max: usize) - .max(header.len()) } -/// The project table. Roots are long, so ROOT takes whatever the other columns +/// The project table. Roots are long, so ROOTS takes whatever the other columns /// leave and gets truncated into it. -pub fn render_projects(projects: &[ProjectEntry], styles: &Styles) -> String { +/// +/// `roots` is this machine's attachments, by project id — a project may have +/// several (worktrees), and one copied from another machine may have none here +/// at all. +/// +/// Several roots are comma-joined while they fit, and collapse to the first +/// plus `+N more` when they do not. An ellipsis in the middle of the second +/// path would say less than the count does: what a reader wants from a wide +/// list is *how many*, and one full path to recognise the project by. +pub fn render_projects( + projects: &[ProjectEntry], + roots: &BTreeMap>, + styles: &Styles, +) -> String { + let unattached = projects.iter().any(|p| !roots.contains_key(&p.id)); + let roots_cell = |p: &ProjectEntry, width: usize| { + let paths = match roots.get(&p.id) { + Some(paths) if !paths.is_empty() => paths, + // Not attached *here*. Normal for a project that arrived with a + // copied projects.json, and for a repo resolved by its marker. + _ => return DASH.to_string(), + }; + let shown: Vec = paths.iter().map(|path| render::tilde(path)).collect(); + let joined = shown.join(", "); + if joined.chars().count() <= width || shown.len() == 1 { + return joined; + } + // The count is reserved out of the width rather than left to the row's + // own truncation, which would eat it and leave a bare `…` claiming + // nothing in particular. + let suffix = format!(" +{} more", shown.len() - 1); + let room = width.saturating_sub(suffix.chars().count()); + format!("{}{suffix}", render::truncate(&shown[0], room)) + }; let sync_cell = |p: &ProjectEntry| match &p.sync { Some(sync) => format!("{}:{}", sync.provider, sync.account), None => DASH.to_string(), @@ -882,7 +1152,7 @@ pub fn render_projects(projects: &[ProjectEntry], styles: &Styles) -> String { let header = format!( "{}{gap}{}{gap}{}{gap}{}", pad("ID", id_w), - pad("ROOT", root_w), + pad("ROOTS", root_w), pad("ENVS", envs_w), "SYNC", ); @@ -892,7 +1162,7 @@ pub fn render_projects(projects: &[ProjectEntry], styles: &Styles) -> String { for project in projects { let id = pad(&render::truncate(&project.id, id_w), id_w); let root = pad( - &render::truncate(&render::tilde(&project.root), root_w), + &render::truncate(&roots_cell(project, root_w), root_w), root_w, ); let envs = pad(&render::truncate(&envs_cell(project), envs_w), envs_w); @@ -908,6 +1178,37 @@ pub fn render_projects(projects: &[ProjectEntry], styles: &Styles) -> String { out.push_str(line.trim_end()); out.push('\n'); } + // An attachment whose project is not registered here has no row to appear + // in, and `find_by_dir` skips it in silence. This footer is the only place + // it is ever visible, which is the whole reason it exists. + let dangling: Vec<&str> = roots + .keys() + .filter(|id| !projects.iter().any(|p| &p.id == *id)) + .map(String::as_str) + .collect(); + if !dangling.is_empty() { + out.push_str(&styles.paint( + dim(), + &format!( + "attached to project{} no longer registered here: {} — `pb env detach --dir \ + ` clears them", + plural(dangling.len()), + dangling.join(", ") + ), + )); + out.push('\n'); + } + // A dash under ROOTS reads as "broken" unless it is explained once. + if unattached { + out.push_str(&styles.paint( + dim(), + &format!( + "{DASH} no directory on this machine is attached; a checkout with a committed \ + {MARKER_FILE} resolves without one, or `pb env attach `" + ), + )); + out.push('\n'); + } out } @@ -1034,16 +1335,17 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let registry = EnvRegistry::new( dir.path().join("projects.json"), + dir.path().join("attachments.json"), Box::new(MemoryKeystore::new()), ); (dir, registry) } - /// One project, one environment, both layers, one override. + /// One project attached to one directory, one environment, both layers, + /// one override. fn seeded(registry: &EnvRegistry) { - registry - .register("pathors", "/repos/pathors", "dev") - .unwrap(); + registry.register("pathors", "dev").unwrap(); + registry.attach("/repos/pathors", "pathors").unwrap(); registry .replace_synced( "pathors", @@ -1065,6 +1367,18 @@ mod tests { .unwrap(); } + /// This machine's attachments, in the shape `render_projects` wants. + fn roots_of(registry: &EnvRegistry) -> BTreeMap> { + let mut roots: BTreeMap> = BTreeMap::new(); + for attachment in registry.attachments().unwrap() { + roots + .entry(attachment.project) + .or_default() + .push(attachment.root); + } + roots + } + fn var(name: &str, source: EnvVarSource) -> EnvVarInfo { EnvVarInfo { name: name.to_string(), @@ -1078,9 +1392,8 @@ mod tests { fn test_projects_table_is_plain_and_aligned_without_color() { let (_dir, registry) = vault(); seeded(®istry); - registry - .register("side", "/repos/side-project", "dev") - .unwrap(); + registry.register("side", "dev").unwrap(); + registry.attach("/repos/side-project", "side").unwrap(); registry .set_sync( "pathors", @@ -1095,7 +1408,7 @@ mod tests { .unwrap(); let projects = registry.projects().unwrap(); - let out = render_projects(&projects, &Styles::new(false)); + let out = render_projects(&projects, &roots_of(®istry), &Styles::new(false)); assert!(!out.contains('\u{1b}'), "plain mode must emit no ANSI"); let lines: Vec<&str> = out.lines().collect(); @@ -1108,11 +1421,81 @@ mod tests { assert!(lines[2].contains(DASH), "{out}"); // Every column starts at the same offset on every row. - let col = lines[0].find("ROOT").unwrap(); + let col = lines[0].find("ROOTS").unwrap(); assert!(lines[1][col..].starts_with("/repos/pathors"), "{out}"); assert!(lines[2][col..].starts_with("/repos/side-project"), "{out}"); } + #[test] + fn test_the_roots_column_counts_worktrees_and_explains_a_dash() { + let (_dir, registry) = vault(); + registry.register("pathors", "dev").unwrap(); + registry.attach("/repos/pathors", "pathors").unwrap(); + registry + .attach("/repos/pathors-worktrees/feature-a", "pathors") + .unwrap(); + registry + .attach("/repos/pathors-worktrees/feature-b", "pathors") + .unwrap(); + // Registered, and attached nowhere on this machine — what a copied + // projects.json looks like before anybody checks the repo out. + registry.register("elsewhere", "dev").unwrap(); + + let projects = registry.projects().unwrap(); + let out = render_projects(&projects, &roots_of(®istry), &Styles::new(false)); + let lines: Vec<&str> = out.lines().collect(); + + // Three roots do not fit the column, so the first one stays whole and + // the rest become a count rather than an ellipsis. + assert!(lines[1].contains("/repos/pathors "), "{out}"); + assert!(lines[1].contains("+2 more"), "{out}"); + assert!(!lines[1].contains("feature-a"), "{out}"); + + assert!(lines[2].starts_with("elsewhere"), "{out}"); + assert!(lines[2].contains(DASH), "{out}"); + // And the dash is explained once, at the bottom, rather than read as a + // project that is somehow broken. + assert!(lines[3].starts_with(DASH), "{out}"); + assert!(lines[3].contains(MARKER_FILE), "{out}"); + assert!(lines[3].contains("pb env attach"), "{out}"); + assert_eq!(lines.len(), 4, "{out}"); + + // Two short roots still fit, and are simply both shown. + let (_dir, registry) = vault(); + registry.register("pathors", "dev").unwrap(); + registry.attach("/a", "pathors").unwrap(); + registry.attach("/b", "pathors").unwrap(); + let out = render_projects( + ®istry.projects().unwrap(), + &roots_of(®istry), + &Styles::new(false), + ); + assert!(out.contains("/a, /b"), "{out}"); + assert!(!out.contains("more"), "{out}"); + // Every project has a root here, so nothing is explained that does not + // need explaining. + assert_eq!(out.lines().count(), 2, "{out}"); + } + + #[test] + fn test_an_attachment_to_a_forgotten_project_is_named_not_hidden() { + let (_dir, registry) = vault(); + registry.register("pathors", "dev").unwrap(); + registry.attach("/repos/pathors", "pathors").unwrap(); + + // What a projects.json copied from another machine leaves behind: a + // root pointing at a project this registry does not have. `find_by_dir` + // skips it silently, so the table is where it has to surface. + let mut roots = roots_of(®istry); + roots.insert("ghost".to_string(), vec![PathBuf::from("/repos/ghost")]); + + let out = render_projects(®istry.projects().unwrap(), &roots, &Styles::new(false)); + let last = out.lines().last().unwrap(); + assert!(last.contains("no longer registered here"), "{out}"); + assert!(last.contains("ghost"), "{out}"); + assert!(last.contains("pb env detach"), "{out}"); + } + #[test] fn test_list_names_the_layers_and_never_shows_a_value() { let (_dir, registry) = vault(); @@ -1392,6 +1775,179 @@ mod tests { assert!(err.contains("not a JSON object"), "{err}"); } + // --- init, markers and the second worktree ------------------------------ + + /// A real directory under a tempdir, because `init` reads and writes a + /// marker file in it. + fn workdir(dir: &tempfile::TempDir, relative: &str) -> PathBuf { + let path = dir.path().join(relative); + std::fs::create_dir_all(&path).unwrap(); + path + } + + fn marker_text(root: &Path) -> String { + std::fs::read_to_string(root.join(MARKER_FILE)).unwrap() + } + + #[test] + fn test_init_registers_attaches_and_leaves_a_marker_to_commit() { + let (dir, registry) = vault(); + let root = workdir(&dir, "repos/pathors"); + + let done = init(®istry, &root, None, "dev", true).unwrap(); + assert_eq!(done.entry.id, "pathors"); + assert!(!done.shared); + assert_eq!(done.attachment.root, root); + assert_eq!(done.marker, Some(root.join(MARKER_FILE))); + assert!(!done.marker_was_there, "there was nothing here to find"); + assert!(marker_text(&root).contains("project = \"pathors\"")); + + // Both routes now answer for this directory, and the marker alone would + // answer on a machine that never attached it. + assert_eq!(registry.attachments_of("pathors").unwrap(), vec![root]); + assert_eq!( + read_marker(&done.attachment.root).unwrap().as_deref(), + Some("pathors") + ); + } + + #[test] + fn test_no_marker_leaves_the_repo_alone() { + let (dir, registry) = vault(); + let root = workdir(&dir, "repos/pathors"); + + let done = init(®istry, &root, None, "dev", false).unwrap(); + assert!(done.marker.is_none()); + assert!(!root.join(MARKER_FILE).exists()); + // The machine still resolves it; nobody else's checkout will. + assert_eq!( + registry.find_by_dir(&root).unwrap().map(|p| p.id), + Some("pathors".to_string()) + ); + } + + #[test] + fn test_a_second_worktree_joins_the_project_instead_of_colliding() { + let (dir, registry) = vault(); + let first = workdir(&dir, "repos/pathors"); + init(®istry, &first, None, "dev", true).unwrap(); + + // A second checkout of the same repo — a worktree named after its + // branch, so the directory name says nothing. The marker is what makes + // this the same project. + let second = workdir(&dir, "repos/pathors-worktrees/feature-a"); + std::fs::copy(first.join(MARKER_FILE), second.join(MARKER_FILE)).unwrap(); + + let done = init(®istry, &second, None, "dev", true).unwrap(); + assert!( + done.shared, + "the duplicate id should have joined, not failed" + ); + assert_eq!(done.entry.id, "pathors"); + assert_eq!(registry.projects().unwrap().len(), 1); + assert_eq!( + registry.attachments_of("pathors").unwrap(), + vec![first, second.clone()] + ); + // Idempotent: the marker it already carries is the one it wanted, so + // nothing was written and nothing is asked of the user. + assert_eq!(done.marker, Some(second.join(MARKER_FILE))); + assert!(done.marker_was_there); + } + + #[test] + fn test_init_refuses_to_take_a_directory_another_project_claims() { + let (dir, registry) = vault(); + let root = workdir(&dir, "repos/pathors"); + std::fs::write(root.join(MARKER_FILE), "project = \"upstream\"\n").unwrap(); + + let err = init(®istry, &root, Some("fork"), "dev", true) + .unwrap_err() + .to_string(); + assert!(err.contains("already claims project `upstream`"), "{err}"); + assert!(err.contains("drop --id"), "{err}"); + assert!(err.contains("pb env attach"), "{err}"); + assert!(err.contains("--no-marker"), "{err}"); + + // Nothing was registered, nothing was attached, and the repo's file is + // exactly as it was. + assert!(registry.projects().unwrap().is_empty()); + assert!(registry.attachments().unwrap().is_empty()); + assert_eq!(marker_text(&root), "project = \"upstream\"\n"); + + // --no-marker is the way through: no marker is written, so there is + // nothing to refuse — and the attachment beats the marker anyway. + let done = init(®istry, &root, Some("fork"), "dev", false).unwrap(); + assert!(!done.shared); + assert_eq!(done.entry.id, "fork"); + assert_eq!( + registry.find_by_dir(&root).unwrap().map(|p| p.id), + Some("fork".to_string()) + ); + } + + #[test] + fn test_a_fresh_clone_registers_under_the_name_the_repo_committed() { + let (dir, registry) = vault(); + // The checkout directory is called something else entirely, which is + // the normal case: `git clone ./work`, or a worktree named after + // a branch. + let root = workdir(&dir, "checkouts/work"); + std::fs::write(root.join(MARKER_FILE), "project = \"pathors\"\n").unwrap(); + // Resolution fails here — the registry never travelled — and that is + // precisely the state `pb env init` is run to leave. + assert!(registry.find_by_dir(&root).is_err()); + + let done = init(®istry, &root, None, "dev", true).unwrap(); + assert!(!done.shared); + assert_eq!( + done.entry.id, "pathors", + "the committed name should win over the directory's" + ); + assert_eq!( + registry.find_by_dir(&root).unwrap().map(|p| p.id), + Some("pathors".to_string()) + ); + // And it did not rewrite the file it took the name from. + assert_eq!(marker_text(&root), "project = \"pathors\"\n"); + } + + #[test] + fn test_an_explicit_different_id_still_registers_its_own_project() { + let (dir, registry) = vault(); + let root = workdir(&dir, "repos/pathors"); + init(®istry, &root, None, "dev", true).unwrap(); + + // Inside the same tree, but told to be something else: the marker above + // resolves, and is deliberately not what the user asked for. + let nested = workdir(&dir, "repos/pathors/tools/scraper"); + let done = init(®istry, &nested, Some("scraper"), "dev", false).unwrap(); + assert!(!done.shared); + assert_eq!(done.entry.id, "scraper"); + assert_eq!(registry.projects().unwrap().len(), 2); + // The attachment is deeper than the marker's directory, so it wins. + assert_eq!( + registry.find_by_dir(&nested).unwrap().map(|p| p.id), + Some("scraper".to_string()) + ); + } + + #[test] + fn test_attach_help_says_it_overrides_the_marker() { + let command = ::augment_subcommands(clap::Command::new("env")); + let attach = command + .get_subcommands() + .find(|c| c.get_name() == "attach") + .expect("`pb env attach` is missing"); + let help = attach + .get_long_about() + .or_else(|| attach.get_about()) + .unwrap() + .to_string(); + assert!(help.contains("OVERRIDES"), "{help}"); + assert!(help.contains(MARKER_FILE), "{help}"); + } + #[test] fn test_forgetting_a_project_takes_every_stored_layer_with_it() { let (_dir, registry) = vault(); diff --git a/crates/patchbay-core/src/env_sync.rs b/crates/patchbay-core/src/env_sync.rs index 9985842..79bbb33 100644 --- a/crates/patchbay-core/src/env_sync.rs +++ b/crates/patchbay-core/src/env_sync.rs @@ -288,11 +288,10 @@ mod tests { let store = Arc::new(MemoryKeystore::new()); let registry = EnvRegistry::new( dir.path().join("projects.json"), + dir.path().join("attachments.json"), Box::new(Shared(store.clone())), ); - registry - .register("pathors", "/repos/pathors", "dev") - .unwrap(); + registry.register("pathors", "dev").unwrap(); Rig { _home: home, @@ -483,11 +482,10 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let registry = EnvRegistry::new( dir.path().join("projects.json"), + dir.path().join("attachments.json"), Box::new(MemoryKeystore::new()), ); - registry - .register("pathors", "/repos/pathors", "dev") - .unwrap(); + registry.register("pathors", "dev").unwrap(); let project = registry .set_sync("pathors", sync_for("contact@pathors.com")) .unwrap(); diff --git a/crates/patchbay-core/src/envs.rs b/crates/patchbay-core/src/envs.rs index 2b17949..7981eb7 100644 --- a/crates/patchbay-core/src/envs.rs +++ b/crates/patchbay-core/src/envs.rs @@ -1,4 +1,4 @@ -//! The project env vault: the environment variables a *directory* needs. +//! The project env vault: the environment variables a *project* needs. //! //! [`crate::keys`] holds standalone credentials that belong to a person or a //! machine. This module holds the other half of the same problem: the twenty @@ -6,6 +6,33 @@ //! keys, the feature flags — which today live in a `.env` file that is //! gitignored, undocumented, and different on every laptop. //! +//! **A project is a name, not a path.** `~/.config/patchbay/projects.json` +//! holds ids, environments and sync config and *no absolute path at all*, so it +//! is the same file on every machine you work from. Which directories on *this* +//! machine belong to a project is a separate, machine-local list — +//! [`Attachment`], in `~/.config/patchbay/attachments.json` — because the same +//! repo lives somewhere else on the next laptop, and a manifest that hard-codes +//! `/Users/you/repos/x` is a manifest that cannot travel. +//! +//! One project may have several attached roots. Git worktrees are the case that +//! forces it: `repo/`, `repo/.worktrees/feature-a` and a second clone are the +//! same project and want the same environment, and asking the user to register +//! three projects would give them three vaults to keep in sync by hand. +//! +//! **A repo may also name its own project**, in a [`MARKER_FILE`] committed at +//! its root — one line, `project = "pathors"`. A checkout then resolves with no +//! attach step at all, which is what makes a fresh `git clone` on a new laptop +//! work. See [`EnvRegistry::find_by_dir`] for the precedence rule and the +//! tradeoff that buys. +//! +//! **Taking your environment to a new machine** is therefore: copy +//! `projects.json` over and `pb env pull` to rebuild every synced layer from the +//! remote. Checkouts carrying a marker resolve on their own; anything else takes +//! one `pb env attach `. The local layer deliberately does *not* travel. +//! `.env.local` semantics are per-machine overrides, and a `DATABASE_URL` +//! pointing at a container on the old laptop is exactly the thing that must not +//! follow you. +//! //! **Two layers per environment**, and the split is the whole point: //! //! * `synced` — what the last pull took from the remote (Infisical). Replaced @@ -21,7 +48,7 @@ //! nobody should run. //! //! **The storage split** mirrors the key vault. Variable *names* and where they -//! came from live in `~/.config/patchbay/projects.json` — readable, greppable, +//! came from live in `projects.json` — readable, greppable, //! worthless to an attacker. *Values* live in the OS keychain behind //! [`Keystore`], one item per (project, environment, layer), holding a compact //! JSON object of the whole layer. One keychain round trip per export rather @@ -45,9 +72,20 @@ use crate::paths::Paths; /// Schema version of `projects.json`. Bump on an incompatible change. pub const PROJECTS_FILE_VERSION: u32 = 1; +/// Schema version of `attachments.json`. Bump on an incompatible change. +/// +/// Versioned separately from [`PROJECTS_FILE_VERSION`] on purpose: the two +/// files have different lifetimes. One is copied between machines, the other is +/// rebuilt on each. +pub const ATTACHMENTS_FILE_VERSION: u32 = 1; + /// The environment a project uses when nobody says which. pub const DEFAULT_ENV: &str = "dev"; +/// The file a repo commits to name its own project: `project = ""` at a +/// directory root. See [`read_marker`] and [`EnvRegistry::find_by_dir`]. +pub const MARKER_FILE: &str = ".patchbay.toml"; + // --------------------------------------------------------------------------- // validation // --------------------------------------------------------------------------- @@ -173,13 +211,15 @@ impl SyncConfig { } } -/// One registered project directory. +/// One registered project. +/// +/// **Portable by construction**: not one field here is a path, which is what +/// makes `projects.json` a file you can copy to another machine. Where the +/// project lives *here* is an [`Attachment`]. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct ProjectEntry { /// Slug, unique across the registry, e.g. `"pathors"`. pub id: String, - /// The directory this project *is*: a repo root, usually. - pub root: PathBuf, /// Which environment `pb env` uses when the command does not say. pub default_env: String, pub created_at: DateTime, @@ -247,6 +287,29 @@ pub struct MergedEnv { pub overridden: Vec, } +/// One directory on **this machine** that belongs to a project. +/// +/// The mental model is a symlink: the directory is pointed at a project that is +/// managed centrally, and the project itself knows nothing about it. Several +/// roots may point at one project (worktrees, a second clone); one root points +/// at exactly one project. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Attachment { + /// The directory, as the caller gave it — absolute, in practice. + pub root: PathBuf, + /// The project id it resolves to. May name a project that no longer + /// exists; see [`EnvRegistry::find_by_dir`]. + pub project: String, +} + +/// On-disk shape of [`MARKER_FILE`]. Deliberately not `deny_unknown_fields`: +/// keys patchbay does not know yet are room for a later version to use without +/// making today's builds reject a repo they could otherwise resolve. +#[derive(Debug, Deserialize)] +struct MarkerToml { + project: String, +} + /// On-disk shape of `projects.json`. #[derive(Debug, Serialize, Deserialize)] struct ProjectsFile { @@ -254,37 +317,124 @@ struct ProjectsFile { projects: Vec, } +/// On-disk shape of `attachments.json`. Machine-local, and never part of any +/// migration or export story — see [`crate::paths::Paths::attachments_file`]. +#[derive(Debug, Serialize, Deserialize)] +struct AttachmentsFile { + version: u32, + attachments: Vec, +} + +// --------------------------------------------------------------------------- +// the marker file +// --------------------------------------------------------------------------- + +/// The project a directory's own [`MARKER_FILE`] names, if it has one. +/// +/// The file is TOML with exactly one key that means anything — +/// `project = "pathors"` — validated as a project id, because a marker that +/// names something no registry could ever hold is a typo, not a claim. +/// +/// A **missing** file is `Ok(None)`: most directories have none, and that is +/// the normal case, not a failure. A file that is *present* and unreadable — +/// broken TOML, no `project` key, an id that is not a slug — is an error naming +/// the file. Somebody committed that marker on purpose and every checkout of +/// that repo will hit it; failing loudly once is cheaper than every clone +/// silently resolving to nothing. +pub fn read_marker(dir: &Path) -> anyhow::Result> { + let path = dir.join(MARKER_FILE); + let Some(text) = read_text(&path)? else { + return Ok(None); + }; + let marker: MarkerToml = toml::from_str(&text).map_err(|e| { + anyhow::anyhow!( + "{} is not a readable patchbay marker: {e}; it holds one key, `project = \"\"`", + path.display() + ) + })?; + validate_project_id(&marker.project) + .map_err(|e| anyhow::anyhow!("{} names an unusable project: {e}", path.display()))?; + Ok(Some(marker.project)) +} + +/// Write the [`MARKER_FILE`] that makes `dir` resolve to `project_id` in any +/// checkout, and return the path written. +/// +/// A plain write with default permissions, unlike everything else this module +/// touches: the file holds a project *name*, it is meant to be committed, and a +/// `0600` file in a repo would only confuse the next person to `ls -l` it. +/// +/// Idempotent when the marker already names this project — the file is left +/// exactly as it is, so a comment or a future key somebody added survives. +/// Re-pointing an existing marker at a **different** project is refused: that is +/// a change to what every checkout of the repo resolves to, and it should be a +/// deliberate edit rather than the side effect of running a command in the wrong +/// directory. +pub fn write_marker(dir: &Path, project_id: &str) -> anyhow::Result { + validate_project_id(project_id)?; + let path = dir.join(MARKER_FILE); + + if let Some(existing) = read_marker(dir)? { + if existing == project_id { + return Ok(path); + } + anyhow::bail!( + "{} already names project `{existing}`, not `{project_id}`; delete it first if the \ + repo should change hands, or bind this directory alone with `pb env attach \ + {project_id}` (an attachment beats the marker and stays on this machine)", + path.display() + ); + } + + let body = format!( + "# patchbay project marker — commit this file.\n\ + # Every checkout of this repo resolves to this project's environments on a\n\ + # machine whose registry holds it (`pb env projects`).\n\ + project = \"{project_id}\"\n" + ); + std::fs::write(&path, body) + .map_err(|e| anyhow::anyhow!("could not write {}: {e}", path.display()))?; + Ok(path) +} + // --------------------------------------------------------------------------- // registry // --------------------------------------------------------------------------- -/// The env vault: a metadata file plus a [`Keystore`] for the values. +/// The env vault: a portable metadata file, a machine-local attachment file, +/// and a [`Keystore`] for the values. /// -/// Stateless between calls, like [`crate::keys::KeyRegistry`]: the file is +/// Stateless between calls, like [`crate::keys::KeyRegistry`]: the files are /// re-read on every operation, so a pull from the CLI is immediately visible to /// a running MCP server. pub struct EnvRegistry { path: PathBuf, + attachments_path: PathBuf, store: Box, } impl EnvRegistry { - /// Bind to an explicit metadata path and keystore. Tests use this with a + /// Bind to explicit file locations and a keystore. Tests use this with a /// tempdir and [`crate::keystore::MemoryKeystore`]. - pub fn new(path: impl Into, store: Box) -> Self { + pub fn new( + path: impl Into, + attachments_path: impl Into, + store: Box, + ) -> Self { Self { path: path.into(), + attachments_path: attachments_path.into(), store, } } - /// Bind to the location [`Paths`] reports, with the given keystore. + /// Bind to the locations [`Paths`] reports, with the given keystore. pub fn with_paths(paths: &Paths, store: Box) -> Self { - Self::new(paths.projects_file(), store) + Self::new(paths.projects_file(), paths.attachments_file(), store) } - /// The real vault on this machine: `~/.config/patchbay/projects.json` plus - /// the macOS Keychain. + /// The real vault on this machine: `~/.config/patchbay/projects.json`, + /// `~/.config/patchbay/attachments.json` and the macOS Keychain. pub fn detect() -> anyhow::Result { let paths = Paths::detect()?; Ok(Self::with_paths( @@ -293,10 +443,16 @@ impl EnvRegistry { )) } + /// The portable project manifest. pub fn path(&self) -> &Path { &self.path } + /// The machine-local attachment list. + pub fn attachments_path(&self) -> &Path { + &self.attachments_path + } + pub fn store_name(&self) -> &'static str { self.store.describe() } @@ -314,32 +470,105 @@ impl EnvRegistry { Ok(self.projects()?.into_iter().find(|p| p.id == id)) } - /// The project `dir` belongs to: the registered project whose root is `dir` - /// or an ancestor of it. When several match — a repo registered inside a - /// monorepo — the deepest root wins, because that is the more specific - /// answer. + /// Every attachment on this machine, sorted by root. + pub fn attachments(&self) -> anyhow::Result> { + Ok(self.load_attachments()?.attachments) + } + + /// The directories on this machine attached to one project, sorted. + /// + /// Empty is a normal answer: a project copied over with `projects.json` has + /// no attachment here until somebody makes one. + pub fn attachments_of(&self, project_id: &str) -> anyhow::Result> { + Ok(self + .attachments()? + .into_iter() + .filter(|a| a.project == project_id) + .map(|a| a.root) + .collect()) + } + + /// The project `dir` belongs to, by two routes in a fixed order. + /// + /// 1. **An attachment.** The project whose attached root is `dir` or an + /// ancestor of it. When several match — a repo attached inside an + /// attached monorepo — the deepest root wins, because that is the more + /// specific answer. A match here ends the search. + /// 2. **A committed [`MARKER_FILE`]**, looked for in `dir` and then up + /// through its ancestors; the nearest one wins, for the same + /// specific-beats-general reason. /// - /// A pure path-prefix comparison, with no canonicalization: resolving - /// symlinks here would mean the answer depends on the filesystem's mood, - /// and `/tmp` on macOS is itself a symlink. A checkout reached through a - /// symlinked path therefore will not match — pass `--project` there. + /// Both comparisons are pure path prefixes, with no canonicalization: + /// resolving symlinks here would mean the answer depends on the + /// filesystem's mood, and `/tmp` on macOS is itself a symlink. A checkout + /// reached through a symlinked path therefore will not match an + /// attachment — pass `--project` there, or commit a marker, which is found + /// by walking up from whatever path the caller actually used. + /// + /// # Why an attachment beats a marker + /// + /// An attachment is a deliberate, local act: somebody stood in that + /// directory and said which project it belongs to. A marker is whatever the + /// repo happens to ship. When the two disagree the person at the keyboard + /// wins, so `pb env attach` is always the way to override a marker — and + /// nothing a repo can contain takes that override away. + /// + /// # The tradeoff the marker buys, stated plainly + /// + /// Resolving by repo content means **repo content selects the project**: + /// cloning a repository whose marker names `pathors` is enough to make + /// `pb env run` inject that project's variables there. This is accepted + /// deliberately, on the assumption that the repos on this machine are + /// internal ones. Two things bound it: a marker can only *name* a project + /// that already exists in this machine's own `projects.json` — it cannot + /// define sync config, an account, or anything else — and an explicit + /// attachment always wins. Somebody who works from untrusted checkouts + /// should not commit markers and should attach instead. + /// + /// A **dangling** attachment — one whose project has since been forgotten — + /// is skipped rather than fatal, and the next-deepest match is considered. + /// [`EnvRegistry::forget`] is what stops them accumulating; this is only the + /// belt to that pair of braces, because `projects.json` can also be + /// hand-edited or replaced wholesale by a copy from another machine. + /// + /// A marker naming an unknown project is **not** treated the same way. It + /// is an error, because it is an explicit claim rather than leftover state: + /// the fix is to bring the registry over from the machine that has that + /// project, and "the clone worked but `pb env` sees nothing here" hides + /// exactly that. pub fn find_by_dir(&self, dir: &Path) -> anyhow::Result> { - let mut best: Option = None; - for project in self.projects()? { - if !dir.starts_with(&project.root) { + let projects = self.projects()?; + + let mut matches: Vec = self + .attachments()? + .into_iter() + .filter(|a| dir.starts_with(&a.root)) + .collect(); + // Deepest first, so the first attachment with a live project wins. + matches.sort_by_key(|a| std::cmp::Reverse(a.root.components().count())); + if let Some(attached) = matches + .into_iter() + .find_map(|a| projects.iter().find(|p| p.id == a.project).cloned()) + { + return Ok(Some(attached)); + } + + // Nothing on this machine claims the directory. What does the repo say? + for ancestor in dir.ancestors() { + let Some(claimed) = read_marker(ancestor)? else { continue; - } - let deeper = match &best { - None => true, - Some(current) => { - project.root.components().count() > current.root.components().count() - } }; - if deeper { - best = Some(project); - } + return match projects.iter().find(|p| p.id == claimed) { + Some(project) => Ok(Some(project.clone())), + None => Err(anyhow::anyhow!( + "{} names project `{claimed}`, but this machine's registry has no project \ + `{claimed}`; copy your projects.json from the machine that has it, or \ + register it here with `pb env init --id {claimed}`", + ancestor.join(MARKER_FILE).display() + )), + }; } - Ok(best) + Ok(None) } /// Every variable name in one environment and where it comes from. @@ -413,39 +642,26 @@ impl EnvRegistry { // --- writes ------------------------------------------------------------- - /// Register a directory as a project. No keychain item is created: an - /// environment appears on the first write to it. - pub fn register( - &self, - id: &str, - root: impl Into, - default_env: &str, - ) -> anyhow::Result { + /// Register a project — a name and a default environment, nothing more. + /// + /// No directory is involved: point one at it with [`EnvRegistry::attach`]. + /// No keychain item is created either — an environment appears on the first + /// write to it. + pub fn register(&self, id: &str, default_env: &str) -> anyhow::Result { validate_project_id(id)?; validate_env_name(default_env)?; - let root = root.into(); let mut file = self.load()?; - if let Some(existing) = file.projects.iter().find(|p| p.id == id) { - anyhow::bail!( - "a project is already registered as `{id}` ({}); pick another id, or remove that \ - one with `pb env forget --project {id}`", - existing.root.display() - ); - } - if let Some(existing) = file.projects.iter().find(|p| p.root == root) { + if file.projects.iter().any(|p| p.id == id) { anyhow::bail!( - "{} is already registered as project `{}`; use that project, or remove it with \ - `pb env forget --project {}`", - root.display(), - existing.id, - existing.id + "a project is already registered as `{id}`; pick another id, attach this \ + directory to it with `pb env attach {id}`, or remove it with `pb env forget \ + --project {id}`" ); } let entry = ProjectEntry { id: id.to_string(), - root, default_env: default_env.to_string(), created_at: Utc::now(), environments: BTreeMap::new(), @@ -456,6 +672,81 @@ impl EnvRegistry { Ok(entry) } + /// Attach a directory on this machine to a project. + /// + /// `root` is stored exactly as given — callers pass an absolute path, and + /// nothing here canonicalizes or resolves symlinks, for the reason + /// [`EnvRegistry::find_by_dir`] gives. + /// + /// Re-attaching a root to the project it already has is a no-op that + /// succeeds, so `pb env attach` is safe to put in a setup script. + /// Re-pointing it at a *different* project is refused: silently moving a + /// directory between vaults would change what `pb env run` injects there, + /// which is not something a stray command should be able to do. + /// + /// # Why this is an explicit, machine-local act + /// + /// The other route to the same answer is a [`MARKER_FILE`] committed in the + /// repo, and it is the one that makes a fresh clone work. An attachment is + /// the heavier instrument on purpose: it lives outside every repository, in + /// the user's own registry, and it **beats the marker** — so a directory + /// whose repo claims the wrong project, or none, can always be pointed + /// somewhere by hand, and no repo can take that back. See + /// [`EnvRegistry::find_by_dir`] for the full precedence rule. + pub fn attach(&self, root: impl Into, project_id: &str) -> anyhow::Result { + let root = root.into(); + if self.get(project_id)?.is_none() { + anyhow::bail!( + "no project registered as `{project_id}`; register one with `pb env init --id \ + {project_id}`, or see what exists with `pb env projects`" + ); + } + + let mut file = self.load_attachments()?; + if let Some(existing) = file.attachments.iter().find(|a| a.root == root) { + if existing.project == project_id { + // Idempotent: nothing is written, so the file's mtime does not + // move either. + return Ok(existing.clone()); + } + anyhow::bail!( + "{} is already attached to project `{}`, not `{project_id}`; detach it first \ + (`pb env detach --dir {}`) if you meant to move it", + root.display(), + existing.project, + root.display() + ); + } + + let attachment = Attachment { + root, + project: project_id.to_string(), + }; + file.attachments.push(attachment.clone()); + self.save_attachments(&mut file)?; + Ok(attachment) + } + + /// Detach a directory. The project, its environments and its values are + /// untouched — this only forgets that *this machine* maps that path. + pub fn detach(&self, root: &Path) -> anyhow::Result { + let mut file = self.load_attachments()?; + let at = file + .attachments + .iter() + .position(|a| a.root == root) + .ok_or_else(|| { + anyhow::anyhow!( + "nothing is attached at {}; `pb env projects` shows this machine's \ + attachments", + root.display() + ) + })?; + let removed = file.attachments.remove(at); + self.save_attachments(&mut file)?; + Ok(removed) + } + /// Point a project at a remote, replacing whatever it was linked to. pub fn set_sync(&self, id: &str, sync: SyncConfig) -> anyhow::Result { if sync.provider != "infisical" { @@ -476,12 +767,20 @@ impl EnvRegistry { Ok(updated) } - /// Unregister a project: the metadata entry and every stored value for - /// every environment, both layers. + /// Unregister a project: this machine's attachments to it, the metadata + /// entry, and every stored value for every environment, both layers. /// - /// Metadata first, keychain second, with the same both-or-neither rule as - /// the key vault. A value that is already absent is not an error — that is - /// what [`Keystore::delete`] returning `Ok(false)` is for. + /// **Attachments go first.** They are machine-local convenience state, so + /// losing them costs an `attach`; what must not survive is an attachment + /// pointing at a project that no longer exists. Taking them first also + /// keeps every failure path re-runnable: if a keychain delete then fails, + /// the project entry is restored and `pb env forget` can simply be run + /// again, whereas removing them last would leave a dangling attachment that + /// no `forget` could ever reach. + /// + /// Metadata then keychain, with the same both-or-neither rule as the key + /// vault. A value that is already absent is not an error — that is what + /// [`Keystore::delete`] returning `Ok(false)` is for. pub fn forget(&self, id: &str) -> anyhow::Result { let mut file = self.load()?; let at = file @@ -491,6 +790,12 @@ impl EnvRegistry { .ok_or_else(|| unknown_project(id))?; let entry = file.projects.remove(at); + let mut attachments = self.load_attachments()?; + if attachments.attachments.iter().any(|a| a.project == id) { + attachments.attachments.retain(|a| a.project != id); + self.save_attachments(&mut attachments)?; + } + let previous = self.read_raw()?; self.save(&file)?; for env in entry.environments.keys() { @@ -742,14 +1047,7 @@ impl EnvRegistry { // --- file plumbing ------------------------------------------------------ fn read_raw(&self) -> anyhow::Result> { - match std::fs::read_to_string(&self.path) { - Ok(text) => Ok(Some(text)), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), - Err(e) => Err(anyhow::anyhow!( - "could not read {}: {e}", - self.path.display() - )), - } + read_text(&self.path) } fn load(&self) -> anyhow::Result { @@ -791,12 +1089,12 @@ impl EnvRegistry { version: PROJECTS_FILE_VERSION, projects: file.projects.clone(), })?; - self.write_atomic(&body) + write_atomic(&self.path, &body) } fn restore(&self, previous: Option<&str>) -> anyhow::Result<()> { match previous { - Some(text) => self.write_atomic(text), + Some(text) => write_atomic(&self.path, text), // There was no file before; removing it is the true rollback. None => match std::fs::remove_file(&self.path) { Ok(()) => Ok(()), @@ -809,29 +1107,49 @@ impl EnvRegistry { } } - fn write_atomic(&self, body: &str) -> anyhow::Result<()> { - let dir = self - .path - .parent() - .ok_or_else(|| anyhow::anyhow!("{} has no parent directory", self.path.display()))?; - std::fs::create_dir_all(dir) - .map_err(|e| anyhow::anyhow!("could not create {}: {e}", dir.display()))?; - - let tmp = self.path.with_extension("json.tmp"); - std::fs::write(&tmp, body) - .map_err(|e| anyhow::anyhow!("could not write {}: {e}", tmp.display()))?; - // Variable names are not secret, but which of them a machine holds is - // nobody else's business. - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600)) - .map_err(|e| anyhow::anyhow!("could not chmod {}: {e}", tmp.display()))?; + // --- the attachment file ------------------------------------------------ + // Same discipline as the project file: missing is empty, malformed is a + // hard error naming the file, a newer version is refused rather than + // rewritten. + + fn load_attachments(&self) -> anyhow::Result { + let empty = || AttachmentsFile { + version: ATTACHMENTS_FILE_VERSION, + attachments: Vec::new(), + }; + let Some(text) = read_text(&self.attachments_path)? else { + return Ok(empty()); + }; + if text.trim().is_empty() { + return Ok(empty()); } - std::fs::rename(&tmp, &self.path).map_err(|e| { - let _ = std::fs::remove_file(&tmp); - anyhow::anyhow!("could not replace {}: {e}", self.path.display()) - }) + let file: AttachmentsFile = serde_json::from_str(&text).map_err(|e| { + anyhow::anyhow!( + "{} is not a readable patchbay attachment list: {e}", + self.attachments_path.display() + ) + })?; + if file.version > ATTACHMENTS_FILE_VERSION { + anyhow::bail!( + "{} was written by a newer patchbay (file version {}, this build understands {}); \ + upgrade rather than risk rewriting it", + self.attachments_path.display(), + file.version, + ATTACHMENTS_FILE_VERSION + ); + } + Ok(file) + } + + /// Sorted by root, so a diff of this file between two moments shows what + /// actually changed rather than what got appended. + fn save_attachments(&self, file: &mut AttachmentsFile) -> anyhow::Result<()> { + file.attachments.sort_by(|a, b| a.root.cmp(&b.root)); + let body = serde_json::to_string_pretty(&AttachmentsFile { + version: ATTACHMENTS_FILE_VERSION, + attachments: file.attachments.clone(), + })?; + write_atomic(&self.attachments_path, &body) } } @@ -839,6 +1157,41 @@ impl EnvRegistry { // free functions // --------------------------------------------------------------------------- +/// A file's text, or `None` when it does not exist yet. +fn read_text(path: &Path) -> anyhow::Result> { + match std::fs::read_to_string(path) { + Ok(text) => Ok(Some(text)), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(anyhow::anyhow!("could not read {}: {e}", path.display())), + } +} + +/// Write one of the vault's files atomically: temp file in the same directory, +/// `0600`, then rename over the target. +fn write_atomic(path: &Path, body: &str) -> anyhow::Result<()> { + let dir = path + .parent() + .ok_or_else(|| anyhow::anyhow!("{} has no parent directory", path.display()))?; + std::fs::create_dir_all(dir) + .map_err(|e| anyhow::anyhow!("could not create {}: {e}", dir.display()))?; + + let tmp = path.with_extension("json.tmp"); + std::fs::write(&tmp, body) + .map_err(|e| anyhow::anyhow!("could not write {}: {e}", tmp.display()))?; + // Variable names are not secret, and neither is a directory path — but + // which of them a machine holds is nobody else's business. + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600)) + .map_err(|e| anyhow::anyhow!("could not chmod {}: {e}", tmp.display()))?; + } + std::fs::rename(&tmp, path).map_err(|e| { + let _ = std::fs::remove_file(&tmp); + anyhow::anyhow!("could not replace {}: {e}", path.display()) + }) +} + fn project_mut<'a>(file: &'a mut ProjectsFile, id: &str) -> anyhow::Result<&'a mut ProjectEntry> { file.projects .iter_mut() @@ -1036,7 +1389,7 @@ mod tests { /// A registry over a tempdir with a fake keystore. Nothing here touches the /// real `$HOME` or the real keychain. struct Vault { - _dir: tempfile::TempDir, + dir: tempfile::TempDir, registry: EnvRegistry, store: Arc, } @@ -1068,7 +1421,7 @@ mod tests { let store = Arc::new(store); let registry = EnvRegistry::with_paths(&paths, Box::new(Shared(store.clone()))); Vault { - _dir: dir, + dir, registry, store, } @@ -1078,11 +1431,22 @@ mod tests { vault_with(MemoryKeystore::new()) } + /// A registry over one tempdir, for the tests that need two handles onto + /// the same pair of files. + fn registry_at(dir: &Path, store: Box) -> EnvRegistry { + EnvRegistry::new( + dir.join("projects.json"), + dir.join("attachments.json"), + store, + ) + } + /// A registered project with one environment holding both layers. fn seeded() -> Vault { let v = vault(); + v.registry.register("pathors", DEFAULT_ENV).unwrap(); v.registry - .register("pathors", "/Users/x/repos/pathors", DEFAULT_ENV) + .attach("/Users/x/repos/pathors", "pathors") .unwrap(); v.registry .replace_synced( @@ -1120,20 +1484,19 @@ mod tests { #[test] fn test_register_writes_metadata_and_no_keychain_items() { let v = vault(); - let entry = v - .registry - .register("pathors", "/repos/pathors", "dev") - .unwrap(); + let entry = v.registry.register("pathors", "dev").unwrap(); assert_eq!(entry.id, "pathors"); - assert_eq!(entry.root, PathBuf::from("/repos/pathors")); assert_eq!(entry.default_env, "dev"); assert!(entry.environments.is_empty()); assert!(entry.sync.is_none()); assert_eq!(v.registry.projects().unwrap(), vec![entry]); - // An environment appears on the first write, not on registration. + // An environment appears on the first write, not on registration; and a + // project is a name, so registering one attaches nothing. assert!(v.store.is_empty()); + assert!(v.registry.attachments().unwrap().is_empty()); + assert!(!v.registry.attachments_path().exists()); } #[test] @@ -1141,70 +1504,211 @@ mod tests { let v = vault(); assert!(v.registry.projects().unwrap().is_empty()); assert!(v.registry.get("nope").unwrap().is_none()); + assert!(v.registry.attachments().unwrap().is_empty()); + assert!(v.registry.attachments_of("nope").unwrap().is_empty()); assert!(v .registry .find_by_dir(Path::new("/anywhere")) .unwrap() .is_none()); assert!(!v.registry.path().exists()); + assert!(!v.registry.attachments_path().exists()); } #[test] - fn test_a_duplicate_id_or_root_names_the_conflict() { + fn test_a_duplicate_id_names_the_conflict() { let v = vault(); - v.registry - .register("pathors", "/repos/pathors", "dev") - .unwrap(); + v.registry.register("pathors", "dev").unwrap(); let err = v .registry - .register("pathors", "/repos/elsewhere", "dev") + .register("pathors", "dev") .unwrap_err() .to_string(); assert!(err.contains("already registered as `pathors`"), "{err}"); - assert!(err.contains("/repos/pathors"), "{err}"); + // The way out of a collision is now attach, so it is offered. + assert!(err.contains("pb env attach pathors"), "{err}"); + + assert_eq!(v.registry.projects().unwrap().len(), 1); + } + + #[test] + fn test_register_validates_the_id_and_the_default_env() { + let v = vault(); + let err = v + .registry + .register("Pathors", "dev") + .unwrap_err() + .to_string(); + assert!(err.contains("project id"), "{err}"); + + let err = v + .registry + .register("pathors", "Prod") + .unwrap_err() + .to_string(); + assert!(err.contains("environment name"), "{err}"); + assert!(!v.registry.path().exists()); + } + + // --- attachments -------------------------------------------------------- + + #[test] + fn test_attach_and_detach_round_trip() { + let v = vault(); + v.registry.register("pathors", "dev").unwrap(); + + let made = v.registry.attach("/repos/pathors", "pathors").unwrap(); + assert_eq!(made.root, PathBuf::from("/repos/pathors")); + assert_eq!(made.project, "pathors"); + assert_eq!(v.registry.attachments().unwrap(), vec![made.clone()]); + assert_eq!( + v.registry.attachments_of("pathors").unwrap(), + vec![PathBuf::from("/repos/pathors")] + ); + assert_eq!( + v.registry + .find_by_dir(Path::new("/repos/pathors/src")) + .unwrap() + .map(|p| p.id), + Some("pathors".to_string()) + ); + + let gone = v.registry.detach(Path::new("/repos/pathors")).unwrap(); + assert_eq!(gone, made); + assert!(v.registry.attachments().unwrap().is_empty()); + // Detaching is not forgetting: the project is untouched. + assert!(v.registry.get("pathors").unwrap().is_some()); + assert!(v + .registry + .find_by_dir(Path::new("/repos/pathors")) + .unwrap() + .is_none()); + } + + #[test] + fn test_re_attaching_the_same_pair_is_a_no_op() { + let v = vault(); + v.registry.register("pathors", "dev").unwrap(); + v.registry.attach("/repos/pathors", "pathors").unwrap(); + let before = std::fs::read_to_string(v.registry.attachments_path()).unwrap(); + + let again = v.registry.attach("/repos/pathors", "pathors").unwrap(); + assert_eq!(again.project, "pathors"); + assert_eq!(v.registry.attachments().unwrap().len(), 1); + assert_eq!( + std::fs::read_to_string(v.registry.attachments_path()).unwrap(), + before + ); + } + + #[test] + fn test_attaching_a_root_to_a_second_project_is_refused() { + let v = vault(); + v.registry.register("pathors", "dev").unwrap(); + v.registry.register("other", "dev").unwrap(); + v.registry.attach("/repos/pathors", "pathors").unwrap(); + let before = std::fs::read_to_string(v.registry.attachments_path()).unwrap(); let err = v .registry - .register("other", "/repos/pathors", "dev") + .attach("/repos/pathors", "other") .unwrap_err() .to_string(); assert!( - err.contains("/repos/pathors is already registered"), + err.contains("already attached to project `pathors`"), "{err}" ); - assert!(err.contains("`pathors`"), "{err}"); + assert!(err.contains("`other`"), "{err}"); + assert!(err.contains("pb env detach"), "{err}"); - assert_eq!(v.registry.projects().unwrap().len(), 1); + // A refusal changes nothing on disk. + assert_eq!( + std::fs::read_to_string(v.registry.attachments_path()).unwrap(), + before + ); + assert_eq!( + v.registry.attachments().unwrap()[0].project, + "pathors", + "the refusal moved the attachment anyway" + ); } #[test] - fn test_register_validates_the_id_and_the_default_env() { + fn test_attaching_to_an_unknown_project_points_at_init() { let v = vault(); let err = v .registry - .register("Pathors", "/repos/p", "dev") + .attach("/repos/ghost", "ghost") .unwrap_err() .to_string(); - assert!(err.contains("project id"), "{err}"); + assert!(err.contains("no project registered as `ghost`"), "{err}"); + assert!(err.contains("pb env init"), "{err}"); + assert!(err.contains("pb env projects"), "{err}"); + assert!(!v.registry.attachments_path().exists()); + } + #[test] + fn test_detaching_an_unattached_path_says_where_to_look() { + let v = vault(); let err = v .registry - .register("pathors", "/repos/p", "Prod") + .detach(Path::new("/repos/nowhere")) .unwrap_err() .to_string(); - assert!(err.contains("environment name"), "{err}"); - assert!(!v.registry.path().exists()); + assert!( + err.contains("nothing is attached at /repos/nowhere"), + "{err}" + ); + assert!(err.contains("pb env projects"), "{err}"); } #[test] - fn test_find_by_dir_prefers_the_deepest_root() { + fn test_every_worktree_of_a_repo_shares_one_project() { let v = vault(); - v.registry.register("mono", "/repos/mono", "dev").unwrap(); + v.registry.register("pathors", "dev").unwrap(); + v.registry.attach("/repos/pathors", "pathors").unwrap(); v.registry - .register("inner", "/repos/mono/apps/web", "dev") + .attach("/repos/pathors-worktrees/feature-a", "pathors") + .unwrap(); + // A second clone somewhere else entirely is the same project too. + v.registry + .attach("/tmp/scratch/pathors", "pathors") .unwrap(); + assert_eq!( + v.registry.attachments_of("pathors").unwrap(), + vec![ + PathBuf::from("/repos/pathors"), + PathBuf::from("/repos/pathors-worktrees/feature-a"), + PathBuf::from("/tmp/scratch/pathors"), + ], + "attachments are stored sorted by root" + ); + for dir in [ + "/repos/pathors/src", + "/repos/pathors-worktrees/feature-a/src", + "/tmp/scratch/pathors", + ] { + assert_eq!( + v.registry + .find_by_dir(Path::new(dir)) + .unwrap() + .map(|p| p.id), + Some("pathors".to_string()), + "{dir} did not resolve to the shared project" + ); + } + } + + #[test] + fn test_find_by_dir_prefers_the_deepest_attached_root() { + let v = vault(); + v.registry.register("mono", "dev").unwrap(); + v.registry.register("inner", "dev").unwrap(); + v.registry.attach("/repos/mono", "mono").unwrap(); + v.registry.attach("/repos/mono/apps/web", "inner").unwrap(); + let hit = |dir: &str| { v.registry .find_by_dir(Path::new(dir)) @@ -1218,9 +1722,263 @@ mod tests { assert_eq!(hit("/repos/mono/apps/web/src"), Some("inner".into())); // A sibling with the same prefix as a *string* is not a child path. assert_eq!(hit("/repos/monolith"), None); + // A directory under no attachment belongs to nothing, even though both + // projects exist. assert_eq!(hit("/elsewhere"), None); } + #[test] + fn test_a_dangling_attachment_is_skipped_not_fatal() { + let v = vault(); + v.registry.register("mono", "dev").unwrap(); + v.registry.register("inner", "dev").unwrap(); + v.registry.attach("/repos/mono", "mono").unwrap(); + v.registry.attach("/repos/mono/apps/web", "inner").unwrap(); + + // File surgery: drop `inner` from the manifest without going through + // `forget`, exactly as copying another machine's projects.json would. + let text = std::fs::read_to_string(v.registry.path()).unwrap(); + let mut file: serde_json::Value = serde_json::from_str(&text).unwrap(); + let projects = file["projects"].as_array_mut().unwrap(); + projects.retain(|p| p["id"] != "inner"); + std::fs::write( + v.registry.path(), + serde_json::to_string_pretty(&file).unwrap(), + ) + .unwrap(); + + // The dangling attachment is still on disk, and is simply not an answer. + assert_eq!(v.registry.attachments().unwrap().len(), 2); + assert_eq!( + v.registry + .find_by_dir(Path::new("/repos/mono/apps/web/src")) + .unwrap() + .map(|p| p.id), + Some("mono".to_string()), + "the deepest match was dead; the next one up should answer" + ); + } + + // --- the marker file ---------------------------------------------------- + + /// A directory under the vault's tempdir, optionally carrying a marker. + /// Real directories, because the marker walk reads the filesystem. + fn dir_with_marker(v: &Vault, relative: &str, marker: Option<&str>) -> PathBuf { + let dir = v.dir.path().join(relative); + std::fs::create_dir_all(&dir).unwrap(); + if let Some(body) = marker { + std::fs::write(dir.join(MARKER_FILE), body).unwrap(); + } + dir + } + + #[test] + fn test_a_marker_resolves_a_directory_nobody_attached() { + let v = vault(); + v.registry.register("pathors", "dev").unwrap(); + let root = dir_with_marker(&v, "clone", Some("project = \"pathors\"\n")); + let deep = dir_with_marker(&v, "clone/apps/web/src", None); + + // Nothing on this machine points at either directory. + assert!(v.registry.attachments().unwrap().is_empty()); + for dir in [&root, &deep] { + assert_eq!( + v.registry.find_by_dir(dir).unwrap().map(|p| p.id), + Some("pathors".to_string()), + "{} did not resolve through the marker", + dir.display() + ); + } + // Unknown keys are room for later versions, not a reason to refuse. + let extra = dir_with_marker( + &v, + "clone2", + Some("project = \"pathors\"\nsomething_new = 42\n"), + ); + assert_eq!(read_marker(&extra).unwrap().as_deref(), Some("pathors")); + // A directory with no marker anywhere above it still belongs to nothing. + assert!(v.registry.find_by_dir(v.dir.path()).unwrap().is_none()); + } + + #[test] + fn test_the_nearest_marker_wins() { + let v = vault(); + v.registry.register("mono", "dev").unwrap(); + v.registry.register("inner", "dev").unwrap(); + dir_with_marker(&v, "mono", Some("project = \"mono\"\n")); + let inner = dir_with_marker(&v, "mono/apps/web", Some("project = \"inner\"\n")); + + assert_eq!( + v.registry.find_by_dir(&inner).unwrap().map(|p| p.id), + Some("inner".to_string()) + ); + assert_eq!( + v.registry + .find_by_dir(&inner.join("src/components")) + .unwrap() + .map(|p| p.id), + Some("inner".to_string()) + ); + assert_eq!( + v.registry + .find_by_dir(&v.dir.path().join("mono/services/api")) + .unwrap() + .map(|p| p.id), + Some("mono".to_string()) + ); + } + + #[test] + fn test_an_attachment_beats_the_marker() { + let v = vault(); + v.registry.register("pathors", "dev").unwrap(); + v.registry.register("fork", "dev").unwrap(); + let root = dir_with_marker(&v, "clone", Some("project = \"pathors\"\n")); + v.registry.attach(&root, "fork").unwrap(); + + // The local, deliberate act wins over what the repo ships — and it wins + // for the whole subtree. + assert_eq!( + v.registry.find_by_dir(&root).unwrap().map(|p| p.id), + Some("fork".to_string()) + ); + assert_eq!( + v.registry + .find_by_dir(&root.join("src")) + .unwrap() + .map(|p| p.id), + Some("fork".to_string()) + ); + + // Detaching hands the directory back to the marker rather than to + // nothing: the repo's claim was never removed. + v.registry.detach(&root).unwrap(); + assert_eq!( + v.registry.find_by_dir(&root).unwrap().map(|p| p.id), + Some("pathors".to_string()) + ); + } + + #[test] + fn test_a_marker_for_an_unknown_project_says_to_bring_the_registry() { + let v = vault(); + v.registry.register("other", "dev").unwrap(); + let root = dir_with_marker(&v, "clone", Some("project = \"pathors\"\n")); + + // Silence would be the wrong answer here: the checkout is fine, the + // registry is what did not travel. + let err = v + .registry + .find_by_dir(&root.join("src")) + .unwrap_err() + .to_string(); + assert!(err.contains(MARKER_FILE), "{err}"); + assert!(err.contains("no project `pathors`"), "{err}"); + assert!(err.contains("projects.json"), "{err}"); + assert!(err.contains("pb env init --id pathors"), "{err}"); + } + + #[test] + fn test_a_broken_marker_is_loud_rather_than_ignored() { + let v = vault(); + v.registry.register("pathors", "dev").unwrap(); + + let bad = dir_with_marker(&v, "a", Some("project = pathors\n")); + let err = read_marker(&bad).unwrap_err().to_string(); + assert!(err.contains("is not a readable patchbay marker"), "{err}"); + assert!(err.contains(MARKER_FILE), "{err}"); + assert!(err.contains("`project = \"\"`"), "{err}"); + + let empty = dir_with_marker(&v, "b", Some("# nothing here\n")); + let err = read_marker(&empty).unwrap_err().to_string(); + assert!(err.contains("project"), "{err}"); + + // An id no registry could hold is a typo, and it is caught at the file. + let shouty = dir_with_marker(&v, "c", Some("project = \"Pathors\"\n")); + let err = read_marker(&shouty).unwrap_err().to_string(); + assert!(err.contains("names an unusable project"), "{err}"); + assert!(err.contains("project id"), "{err}"); + + // And the resolution path surfaces it rather than falling through to a + // marker further up. + dir_with_marker(&v, "", Some("project = \"pathors\"\n")); + assert!(v.registry.find_by_dir(&bad).is_err()); + } + + #[test] + fn test_write_marker_round_trips_and_refuses_to_change_hands() { + let v = vault(); + let root = dir_with_marker(&v, "clone", None); + + let path = write_marker(&root, "pathors").unwrap(); + assert_eq!(path, root.join(MARKER_FILE)); + assert_eq!(read_marker(&root).unwrap().as_deref(), Some("pathors")); + + // Idempotent, and byte-for-byte: a comment or a hand-added key survives + // a second `pb env init`. + let written = std::fs::read_to_string(&path).unwrap(); + assert!(written.contains("commit this file"), "{written}"); + std::fs::write(&path, format!("{written}extra = true\n")).unwrap(); + let before = std::fs::read_to_string(&path).unwrap(); + assert_eq!(write_marker(&root, "pathors").unwrap(), path); + assert_eq!(std::fs::read_to_string(&path).unwrap(), before); + + let err = write_marker(&root, "other").unwrap_err().to_string(); + assert!(err.contains("already names project `pathors`"), "{err}"); + assert!(err.contains("`other`"), "{err}"); + assert!(err.contains("pb env attach other"), "{err}"); + // A refusal changes nothing on disk. + assert_eq!(std::fs::read_to_string(&path).unwrap(), before); + + let err = write_marker(&root, "Nope").unwrap_err().to_string(); + assert!(err.contains("project id"), "{err}"); + } + + #[cfg(unix)] + #[test] + fn test_the_marker_is_a_normal_committable_file() { + use std::os::unix::fs::PermissionsExt; + let v = vault(); + let root = dir_with_marker(&v, "clone", None); + let path = write_marker(&root, "pathors").unwrap(); + + // Unlike the registry files, this one is meant to be committed and read + // by everyone who checks the repo out — so it gets whatever an ordinary + // write gets, not the vault's `0600`. + let control = root.join("control.txt"); + std::fs::write(&control, "x").unwrap(); + let mode = |p: &Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o777; + assert_eq!( + mode(&path), + mode(&control), + "the marker was written with special permissions" + ); + } + + #[test] + fn test_the_projects_file_holds_no_path_from_this_machine() { + let v = vault(); + let root = v.dir.path().join("repos/pathors"); + v.registry.register("pathors", "dev").unwrap(); + v.registry.attach(&root, "pathors").unwrap(); + v.registry.set_local("pathors", "dev", "A", "1").unwrap(); + + // The whole portability promise, as one assertion: a manifest with an + // absolute path in it is a manifest that cannot be copied to another + // machine. + let manifest = std::fs::read_to_string(v.registry.path()).unwrap(); + let here = v.dir.path().to_string_lossy().to_string(); + assert!( + !manifest.contains(&here), + "{here} leaked into the portable manifest:\n{manifest}" + ); + assert!(!manifest.contains("root"), "{manifest}"); + + // And the machine-local file is where it went. + let attachments = std::fs::read_to_string(v.registry.attachments_path()).unwrap(); + assert!(attachments.contains(&here), "{attachments}"); + } + #[test] fn test_forget_takes_every_layer_of_every_environment() { let v = seeded(); @@ -1232,6 +1990,7 @@ mod tests { let entry = v.registry.forget("pathors").unwrap(); assert_eq!(entry.id, "pathors"); assert!(v.registry.projects().unwrap().is_empty()); + assert!(v.registry.attachments().unwrap().is_empty()); assert!( v.store.is_empty(), "keychain items survived the project: {}", @@ -1242,26 +2001,57 @@ mod tests { assert!(err.contains("no project registered as `pathors`"), "{err}"); } + #[test] + fn test_forget_takes_this_machines_attachments_and_leaves_the_rest() { + let v = vault(); + v.registry.register("pathors", "dev").unwrap(); + v.registry.register("side", "dev").unwrap(); + v.registry.attach("/repos/pathors", "pathors").unwrap(); + v.registry + .attach("/repos/pathors-worktrees/a", "pathors") + .unwrap(); + v.registry.attach("/repos/side", "side").unwrap(); + + v.registry.forget("pathors").unwrap(); + + // Both of the forgotten project's roots went; the other project's did + // not. A dangling attachment is what this prevents. + assert_eq!( + v.registry.attachments().unwrap(), + vec![Attachment { + root: PathBuf::from("/repos/side"), + project: "side".to_string(), + }] + ); + assert!(v.registry.attachments_of("pathors").unwrap().is_empty()); + assert!(v + .registry + .find_by_dir(Path::new("/repos/pathors/src")) + .unwrap() + .is_none()); + } + #[test] fn test_forget_keeps_the_project_when_a_delete_fails() { let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("projects.json"); - let ok = EnvRegistry::new(&path, Box::new(MemoryKeystore::new())); - ok.register("pathors", "/repos/pathors", "dev").unwrap(); + let ok = registry_at(dir.path(), Box::new(MemoryKeystore::new())); + ok.register("pathors", "dev").unwrap(); + ok.attach("/repos/pathors", "pathors").unwrap(); ok.set_local("pathors", "dev", "A", "1").unwrap(); - let broken = EnvRegistry::new(&path, Box::new(MemoryKeystore::failing_delete())); + let broken = registry_at(dir.path(), Box::new(MemoryKeystore::failing_delete())); let err = format!("{:#}", broken.forget("pathors").unwrap_err()); assert!(err.contains("the project was kept"), "{err}"); assert_eq!(broken.projects().unwrap().len(), 1); + // The attachment went first, so re-running `forget` is the fix — and + // nothing dangles in the meantime. + assert!(broken.attachments().unwrap().is_empty()); } #[test] fn test_set_sync_replaces_and_only_knows_one_provider() { let v = vault(); - v.registry - .register("pathors", "/repos/pathors", "dev") - .unwrap(); + v.registry.register("pathors", "dev").unwrap(); let entry = v .registry @@ -1364,9 +2154,7 @@ mod tests { #[test] fn test_set_local_creates_the_environment_and_survives_a_reopen() { let v = vault(); - v.registry - .register("pathors", "/repos/pathors", "dev") - .unwrap(); + v.registry.register("pathors", "dev").unwrap(); v.registry .set_local("pathors", "staging", "MY_FLAG", "true") .unwrap(); @@ -1375,7 +2163,11 @@ mod tests { assert_eq!(project.env_names(), vec!["staging"]); // A second registry over the same file and store sees the same thing. - let reopened = EnvRegistry::new(v.registry.path(), Box::new(Shared(v.store.clone()))); + let reopened = EnvRegistry::new( + v.registry.path(), + v.registry.attachments_path(), + Box::new(Shared(v.store.clone())), + ); let merged = reopened.merged("pathors", "staging").unwrap(); assert_eq!(merged.vars["MY_FLAG"], "true"); } @@ -1383,9 +2175,7 @@ mod tests { #[test] fn test_set_local_validates_before_touching_anything() { let v = vault(); - v.registry - .register("pathors", "/repos/pathors", "dev") - .unwrap(); + v.registry.register("pathors", "dev").unwrap(); let err = v .registry @@ -1481,9 +2271,7 @@ mod tests { #[test] fn test_import_local_is_all_or_nothing() { let v = vault(); - v.registry - .register("pathors", "/repos/pathors", "dev") - .unwrap(); + v.registry.register("pathors", "dev").unwrap(); let good: Vec<(String, String)> = vec![ ("A".into(), "1".into()), @@ -1582,7 +2370,11 @@ mod tests { } } - let quiet = EnvRegistry::new(v.registry.path(), Box::new(NoReads)); + let quiet = EnvRegistry::new( + v.registry.path(), + v.registry.attachments_path(), + Box::new(NoReads), + ); let listed = quiet.list("pathors", "dev").unwrap(); let seen: Vec<(&str, EnvVarSource)> = listed.iter().map(|v| (v.name.as_str(), v.source)).collect(); @@ -1615,7 +2407,7 @@ mod tests { assert!(err.contains("has no environment `prod`"), "{err}"); let v = vault(); - v.registry.register("fresh", "/repos/fresh", "dev").unwrap(); + v.registry.register("fresh", "dev").unwrap(); let err = v.registry.list("fresh", "dev").unwrap_err().to_string(); assert!(err.contains("it has none yet"), "{err}"); } @@ -1625,9 +2417,7 @@ mod tests { #[test] fn test_a_keystore_failure_rolls_the_metadata_back() { let v = vault_with(MemoryKeystore::failing_put()); - v.registry - .register("pathors", "/repos/pathors", "dev") - .unwrap(); + v.registry.register("pathors", "dev").unwrap(); let before = std::fs::read_to_string(v.registry.path()).unwrap(); let err = format!( @@ -1655,8 +2445,8 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("projects.json"); let store = Arc::new(MemoryKeystore::new()); - let ok = EnvRegistry::new(&path, Box::new(Shared(store.clone()))); - ok.register("pathors", "/repos/pathors", "dev").unwrap(); + let ok = registry_at(dir.path(), Box::new(Shared(store.clone()))); + ok.register("pathors", "dev").unwrap(); ok.replace_synced( "pathors", "dev", @@ -1668,7 +2458,7 @@ mod tests { .unwrap(); let before = std::fs::read_to_string(&path).unwrap(); - let broken = EnvRegistry::new(&path, Box::new(MemoryKeystore::failing_put())); + let broken = registry_at(dir.path(), Box::new(MemoryKeystore::failing_put())); assert!(broken .replace_synced( "pathors", @@ -1689,7 +2479,7 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("projects.json"); std::fs::write(&path, "{ this is not json").unwrap(); - let registry = EnvRegistry::new(&path, Box::new(MemoryKeystore::new())); + let registry = registry_at(dir.path(), Box::new(MemoryKeystore::new())); let err = registry.projects().unwrap_err().to_string(); assert!( err.contains("not a readable patchbay project registry"), @@ -1702,14 +2492,17 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("projects.json"); std::fs::write(&path, r#"{"version":99,"projects":[]}"#).unwrap(); - let registry = EnvRegistry::new(&path, Box::new(MemoryKeystore::new())); + let registry = registry_at(dir.path(), Box::new(MemoryKeystore::new())); let err = registry.projects().unwrap_err().to_string(); assert!(err.contains("newer patchbay"), "{err}"); } #[test] fn test_a_hand_trimmed_file_still_parses() { - // Only the fields a human would keep: no `environments`, no `sync`. + // Only the fields a human would keep: no `environments`, no `sync` — + // plus a stray `root`, which is what a file written before projects + // became portable looks like. Unknown fields are ignored, so no + // migration code is needed for it. let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("projects.json"); std::fs::write( @@ -1728,19 +2521,84 @@ mod tests { ) .unwrap(); - let registry = EnvRegistry::new(&path, Box::new(MemoryKeystore::new())); + let registry = registry_at(dir.path(), Box::new(MemoryKeystore::new())); let projects = registry.projects().unwrap(); assert_eq!(projects.len(), 1); assert!(projects[0].environments.is_empty()); assert!(projects[0].sync.is_none()); - // And a project with no sync does not grow one on rewrite. + // And a project with no sync does not grow one on rewrite — while the + // stale `root` is dropped rather than carried forward. registry.set_local("pathors", "dev", "A", "1").unwrap(); let rewritten = std::fs::read_to_string(&path).unwrap(); assert!(!rewritten.contains("\"sync\""), "{rewritten}"); + assert!(!rewritten.contains("\"root\""), "{rewritten}"); assert!(rewritten.contains("\"synced_at\": null"), "{rewritten}"); } + // --- the attachment file ------------------------------------------------ + + #[cfg(unix)] + #[test] + fn test_the_attachment_file_is_owner_only_too() { + use std::os::unix::fs::PermissionsExt; + let v = seeded(); + let mode = std::fs::metadata(v.registry.attachments_path()) + .unwrap() + .permissions() + .mode(); + assert_eq!(mode & 0o777, 0o600, "mode was {:o}", mode & 0o777); + } + + #[test] + fn test_a_malformed_attachment_file_is_an_error_naming_it() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("attachments.json"), "{ nope").unwrap(); + let registry = registry_at(dir.path(), Box::new(MemoryKeystore::new())); + + let err = registry.attachments().unwrap_err().to_string(); + assert!( + err.contains("not a readable patchbay attachment list"), + "{err}" + ); + assert!(err.contains("attachments.json"), "{err}"); + // The projects file is a separate story and still reads fine. + assert!(registry.projects().unwrap().is_empty()); + } + + #[test] + fn test_a_newer_attachment_file_version_is_refused() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("attachments.json"), + r#"{"version":99,"attachments":[]}"#, + ) + .unwrap(); + let registry = registry_at(dir.path(), Box::new(MemoryKeystore::new())); + let err = registry.attachments().unwrap_err().to_string(); + assert!(err.contains("newer patchbay"), "{err}"); + } + + #[test] + fn test_the_attachment_file_is_the_shape_it_documents() { + let v = vault(); + v.registry.register("pathors", "dev").unwrap(); + v.registry.attach("/repos/b", "pathors").unwrap(); + v.registry.attach("/repos/a", "pathors").unwrap(); + + let raw = std::fs::read_to_string(v.registry.attachments_path()).unwrap(); + let value: serde_json::Value = serde_json::from_str(&raw).unwrap(); + assert_eq!(value["version"], 1); + // Sorted by root, whatever order they were attached in. + assert_eq!(value["attachments"][0]["root"], "/repos/a"); + assert_eq!(value["attachments"][0]["project"], "pathors"); + assert_eq!(value["attachments"][1]["root"], "/repos/b"); + + // An empty file is an empty list, not a parse error. + std::fs::write(v.registry.attachments_path(), "").unwrap(); + assert!(v.registry.attachments().unwrap().is_empty()); + } + #[test] fn test_an_unreadable_keychain_blob_names_the_account() { let v = seeded(); diff --git a/crates/patchbay-core/src/lib.rs b/crates/patchbay-core/src/lib.rs index c357348..0d2a63c 100644 --- a/crates/patchbay-core/src/lib.rs +++ b/crates/patchbay-core/src/lib.rs @@ -29,6 +29,10 @@ //! env vault** ([`envs`]) holds the same line for the variable sets a repo needs //! — names and provenance on disk, values in the keychain, and pull-only sync //! ([`env_sync`]) so patchbay can never push a local value to a shared remote. +//! A project there is a portable *name*, never a directory: which directories on +//! this machine belong to it is a separate, machine-local list of +//! [`Attachment`]s, so the project manifest can be copied to the next laptop +//! unchanged. pub mod deprecations; pub mod env_sync; @@ -49,7 +53,8 @@ pub mod versions; pub use deprecations::{Advisory, AdvisoryKind}; pub use env_sync::{pull, PullOutcome}; pub use envs::{ - EnvLayer, EnvMeta, EnvRegistry, EnvVarInfo, EnvVarSource, MergedEnv, ProjectEntry, SyncConfig, + Attachment, EnvLayer, EnvMeta, EnvRegistry, EnvVarInfo, EnvVarSource, MergedEnv, ProjectEntry, + SyncConfig, }; pub use keys::{KeyEntry, KeyExpiryState, KeyPatch, KeyRegistry, NewKey}; pub use keys_verify::{verify_key, KeyVerifyOutcome, KeyVerifyStatus}; diff --git a/crates/patchbay-core/src/paths.rs b/crates/patchbay-core/src/paths.rs index 7aa3465..2dd0f2d 100644 --- a/crates/patchbay-core/src/paths.rs +++ b/crates/patchbay-core/src/paths.rs @@ -650,10 +650,25 @@ impl Paths { /// The project env vault's metadata registry. Variable *names* and their /// provenance live here; the values never do — they are in the OS keychain /// (see [`crate::envs`]). + /// + /// Portable: it holds no absolute path, so copying it to another machine is + /// the supported way to take your projects with you. pub fn projects_file(&self) -> PathBuf { self.patchbay_dir().join("projects.json") } + /// Which directories on **this machine** belong to which project (see + /// [`crate::envs::Attachment`]). + /// + /// Machine-local by design, and deliberately kept out of + /// [`Paths::projects_file`] rather than being one more field in it: the + /// paths in here are meaningless on any other machine, so this file is + /// excluded from every migration, copy or export story patchbay has. Moving + /// to a new laptop means re-attaching, not restoring this. + pub fn attachments_file(&self) -> PathBuf { + self.patchbay_dir().join("attachments.json") + } + /// The version-check cache (see [`crate::versions`]). Public information /// about public software — no secrets, so no 0600 handling. pub fn versions_file(&self) -> PathBuf { @@ -691,9 +706,17 @@ mod tests { p.projects_file(), PathBuf::from("/nowhere/.config/patchbay/projects.json") ); + assert_eq!( + p.attachments_file(), + PathBuf::from("/nowhere/.config/patchbay/attachments.json") + ); let p = Paths::for_test("/nowhere").with_env("PATCHBAY_CONFIG_DIR", "/custom/pb"); assert_eq!(p.keys_file(), PathBuf::from("/custom/pb/keys.json")); assert_eq!(p.projects_file(), PathBuf::from("/custom/pb/projects.json")); + assert_eq!( + p.attachments_file(), + PathBuf::from("/custom/pb/attachments.json") + ); } #[test] diff --git a/crates/patchbay-mcp/src/envs.rs b/crates/patchbay-mcp/src/envs.rs index 065f0db..1470514 100644 --- a/crates/patchbay-mcp/src/envs.rs +++ b/crates/patchbay-mcp/src/envs.rs @@ -102,10 +102,17 @@ fn require(envs: &EnvRegistry, id: &str) -> anyhow::Result { envs.get(id)?.ok_or_else(|| unknown_project(id)) } -/// One project as the tools report it: registration, environments with counts, -/// and the sync config. **Metadata only** — this cannot carry a value, because -/// [`ProjectEntry`] does not hold one. -fn describe_project(project: &ProjectEntry) -> Result { +/// One project as the tools report it: registration, this machine's attached +/// roots, environments with counts, and the sync config. **Metadata only** — +/// this cannot carry a value, because [`ProjectEntry`] does not hold one. +/// +/// `roots` is passed in rather than looked up: a project holds no path of its +/// own (that is what makes `projects.json` portable), and the attachments are a +/// separate, machine-local list. +fn describe_project( + project: &ProjectEntry, + roots: &[std::path::PathBuf], +) -> Result { let mut environments = Vec::with_capacity(project.environments.len()); for (name, meta) in &project.environments { // The merged view is a union, not a sum: a local override shares its @@ -126,10 +133,10 @@ fn describe_project(project: &ProjectEntry) -> Result>(), "default_env": project.default_env, "created_at": encode(&project.created_at)?, "environments": environments, @@ -182,10 +189,18 @@ Call this first when the user mentions their project's env, when you are about t and need the project id and the environment names, or when you want to know whether a directory \ is registered at all. -Returns a JSON array of projects: { id, root, default_env, created_at, environments[], sync }. - -- `id` is patchbay's slug for the directory and the value every other env tool takes as \ -`project`. `root` is the directory it means. +Returns a JSON array of projects: { id, roots, default_env, created_at, environments[], sync }. + +- `id` is patchbay's slug for the project and the value every other env tool takes as `project`. \ +A project is a NAME, not a directory: the registry holds no path at all, which is what lets the \ +user copy it between machines. +- `roots[]` are the directories on THIS machine bound to the project — machine-local attachments, \ +several when the user keeps worktrees or a second clone, and an empty list when nobody has bound \ +one here (normal for a project whose registry was copied from another laptop). A repo may also \ +carry a committed `.patchbay.toml` marker naming its project, which resolves a checkout by its \ +CONTENT with no attachment at all — so an empty `roots` does not mean `pb env` fails in that \ +repo, and a path is never proof of where the user is working. If you need the project for a \ +particular directory, ask the user rather than pattern-matching these paths. - `default_env` is the environment used when a call omits `env`. Do not assume it is 'dev'. - `environments[]` is { name, var_count, synced_count, local_count, synced_at }. `var_count` is \ the distinct names a consumer would see (a local override shares its name with the synced \ @@ -200,10 +215,28 @@ run as; `env_map` is patchbay's environment name -> the remote's own slug, for r not returned by this or any other tool.")] async fn list_env_projects(&self) -> Result { let envs = self.envs.clone(); - match offload(move || envs.projects()).await? { - Ok(projects) => { - let described: Result, ErrorData> = - projects.iter().map(describe_project).collect(); + // Both files in one offload, so the roots reported cannot come from a + // different moment than the projects they hang off. + let listed = offload(move || { + let projects = envs.projects()?; + let attachments = envs.attachments()?; + Ok::<_, anyhow::Error>((projects, attachments)) + }) + .await?; + + match listed { + Ok((projects, attachments)) => { + let described: Result, ErrorData> = projects + .iter() + .map(|project| { + let roots: Vec = attachments + .iter() + .filter(|a| a.project == project.id) + .map(|a| a.root.clone()) + .collect(); + describe_project(project, &roots) + }) + .collect(); Ok(json_ok(serde_json::Value::Array(described?))) } Err(err) => Ok(tool_error(err)), @@ -403,7 +436,6 @@ mod tests { use chrono::{DateTime, TimeZone, Utc}; use patchbay_core::envs::{EnvMeta, SyncConfig}; use std::collections::BTreeMap; - use std::path::PathBuf; fn at(rfc: &str) -> DateTime { DateTime::parse_from_rfc3339(rfc) @@ -414,7 +446,6 @@ mod tests { fn project() -> ProjectEntry { ProjectEntry { id: "pathors".into(), - root: PathBuf::from("/repos/pathors"), default_env: "dev".into(), created_at: Utc.timestamp_opt(0, 0).unwrap(), environments: BTreeMap::new(), @@ -423,7 +454,7 @@ mod tests { } fn described(project: &ProjectEntry) -> serde_json::Value { - describe_project(project).unwrap() + describe_project(project, &[]).unwrap() } // --- environment resolution --------------------------------------------- @@ -448,12 +479,31 @@ mod tests { fn test_a_project_reports_its_registration_and_no_environments() { let value = described(&project()); assert_eq!(value["id"], "pathors"); - assert_eq!(value["root"], "/repos/pathors"); + // A project is a name. The singular `root` a project used to claim is + // gone for good; a project with no attachment here reports an empty + // list, which is not the same as being unusable. + assert!(value["root"].is_null(), "{value}"); + assert_eq!(value["roots"].as_array().unwrap().len(), 0); assert_eq!(value["default_env"], "dev"); assert!(value["sync"].is_null()); assert_eq!(value["environments"].as_array().unwrap().len(), 0); } + #[test] + fn test_this_machines_attached_roots_travel_with_the_project() { + let roots = [ + std::path::PathBuf::from("/repos/pathors"), + std::path::PathBuf::from("/repos/pathors-worktrees/feature-a"), + ]; + let value = describe_project(&project(), &roots).unwrap(); + + assert_eq!( + value["roots"], + serde_json::json!(["/repos/pathors", "/repos/pathors-worktrees/feature-a"]), + "worktrees are several roots on one project, not several projects" + ); + } + #[test] fn test_counts_are_a_union_not_a_sum_and_an_unpulled_env_says_so() { let mut p = project(); @@ -622,6 +672,20 @@ mod tests { } } + #[test] + fn test_list_env_projects_explains_what_a_root_is_and_is_not() { + let text = description("list_env_projects"); + // An agent that reads `roots` as "where the project is" would get both + // the empty case and the marker case wrong. + assert!(text.contains("machine-local attachments"), "{text}"); + assert!(text.contains(".patchbay.toml"), "{text}"); + assert!(text.contains("empty list"), "{text}"); + assert!( + text.contains("A project is a NAME, not a directory"), + "{text}" + ); + } + #[test] fn test_pull_env_advertises_its_cost_and_the_account_refusal() { let text = description("pull_env"); diff --git a/docs/env-vault.md b/docs/env-vault.md index a8a66c3..a4b124c 100644 --- a/docs/env-vault.md +++ b/docs/env-vault.md @@ -2,7 +2,7 @@ The key vault holds credentials that belong to a person or a machine. This -holds the other half of the same problem: the twenty variables a *directory* +holds the other half of the same problem: the twenty variables a *repo* needs before it will boot — `DATABASE_URL`, the provider keys, the feature flags. Today they live in a `.env` file that is plaintext on disk, gitignored, undocumented, and different on every laptop. The variables you deliberately @@ -11,29 +11,104 @@ whole job is to never reach anyone else. ```sh cd ~/repos/pathors -pb env init # register this directory as a project +pb env init # register it, and leave a marker to commit pb env pull # fill the synced layer from Infisical pbpaste | pb env set DATABASE_URL # a local override; the value never touches argv pb env diff # what this machine changes about `dev` pb env run -- bun dev # the merged environment, into one child process ``` -Nothing there writes a file. The variable *names* go to -`~/.config/patchbay/projects.json`; the values go to the macOS Keychain. +The only file any of that writes into the repo is the `.patchbay.toml` marker +`init` leaves for you to commit, and it holds a project name and nothing else. +The variable *names* go to `~/.config/patchbay/projects.json`; the values go to +the macOS Keychain. + +### A project is a name, not a path + +`~/.config/patchbay/projects.json` holds project ids, their environments and +where each one pulls from. It holds **no absolute path at all** — there is a +test that asserts exactly that — so it is the same file on every machine you +work from, and copying it is the supported way to take your projects with you. +Which directories on *this* machine belong to which project is a separate list, +`~/.config/patchbay/attachments.json`, because the same repo lives somewhere +else on the next laptop and a manifest that hard-codes `/Users/you/repos/x` is a +manifest that cannot travel. + +A directory resolves to a project two ways, in this order: + +1. **An attachment.** `pb env attach ` binds this directory to a project + that already exists, on this machine only. The project whose attached root is + the directory or an ancestor of it wins; when several match — a service + attached inside an attached monorepo — the deepest root wins, because it is + the more specific answer. `pb env detach` undoes it. +2. **A marker.** A `.patchbay.toml` committed at the repo root, holding one + line that means anything: `project = "pathors"`. patchbay looks for it in the + directory and then up through its ancestors, nearest first. `pb env init` + writes it for you unless you pass `--no-marker`. + +When neither answers, the command says so and names every way in rather than +guessing: -### A project is a directory +``` +no project registered for this directory. Three ways in: `pb env init` here to +register a new project, `pb env attach ` to bind this directory to one that +already exists, or work in a checkout carrying a committed .patchbay.toml, which +resolves on its own. `pb env projects` lists what exists, and --project +overrides all of it for one command +``` + +**An attachment always beats a marker.** An attachment is a deliberate, local +act: somebody stood in that directory and said which project it belongs to. A +marker is whatever the repo happens to ship. When the two disagree the person at +the keyboard wins, and nothing a repo can contain takes that override away. + +A marker can only *name*. It points at a project the machine's own registry +already holds; it cannot define a sync config, an account, an environment or +anything else. One that names a project this machine does not have is a loud +error rather than a silent miss, because it is an explicit claim rather than +leftover state: + +``` +/repos/pathors/.patchbay.toml names project `pathors`, but this machine's +registry has no project `pathors`; copy your projects.json from the machine that +has it, or register it here with `pb env init --id pathors` +``` + +Git worktrees fall out of this for free: every worktree of a repo carries the +same committed marker, so all of them — and a second clone, and a colleague's +checkout — resolve to one project's environments with no setup at all. + +The tradeoff is real and was taken deliberately: **repo content selects the +project**. Cloning a repository whose marker names `pathors` is enough to make +`pb env run` inject that project's variables in it. That is accepted on the +assumption that you run the repos you trust. Two things bound it — a marker can +only name a project you already registered, and an explicit attachment always +wins — so somebody who works from untrusted checkouts should pass `--no-marker` +and attach by hand instead. + +### Taking it to a new machine -`pb env init` registers the current directory under a slug — the directory's -own name unless `--id` says otherwise. Every later command resolves the project -from the cwd by walking up to the nearest registered root, so `pb env run` -inside `src/api/` finds the repo it belongs to. When two registered roots both -contain you — a service registered inside a monorepo that is also registered — -the deeper one wins, because it is the more specific answer. +This is the whole point of the split. A new laptop is three steps: -The match is a plain path-prefix comparison with no symlink resolution. Making -it canonical would make the answer depend on the filesystem's mood, and `/tmp` -on macOS is itself a symlink. A checkout reached through a symlinked path -therefore will not match; pass `--project ` there. +```sh +cp projects.json ~/.config/patchbay/ # from the old machine +git clone git@github.com:you/pathors # the marker comes with the checkout +cd pathors && pb env pull # rebuild the synced layer from Infisical +``` + +A checkout carrying a marker resolves on its own; anything else takes one +`pb env attach `. Attachments deliberately do not travel — they are paths +from a machine that is not this one — so `attachments.json` is excluded from +every migration, copy and export story patchbay has. A project that arrived in a +copied `projects.json` and has no attachment here shows `—` under ROOTS in +`pb env projects`, which is normal, not broken. + +The **local layer deliberately does not travel either**. `.env.local` semantics +are per-machine overrides, and a `DATABASE_URL` pointing at a container on the +old laptop is exactly the thing that must not follow you. What the remote holds +comes back with `pb env pull`; what you set by hand you set again, on purpose. + +### Environments and names Each project has named environments — `dev`, `staging`, `production`, whatever you like — and a `default_env` used when a command does not say (`dev` unless @@ -116,13 +191,16 @@ Nothing runs and nothing is stored. If the guard is somehow satisfied and the real 403 arrives anyway, patchbay recognises the phrase and appends the same advice to Infisical's own message. -`pb env init` picks the pin up for you: it reads `.infisical.json` in the -directory for the `workspaceId` and records the currently active account -alongside it. `pb env link` sets or replaces the same thing by hand, and -`--map dev=development,production=prod` handles the projects whose remote spells -an environment differently — patchbay's name is what the vault records, the -remote's name is what goes on the command line. `--domain` is for self-hosted -and EU instances. +`pb env init` picks the pin up for you when it registers a *new* project: it +reads `.infisical.json` in the directory for the `workspaceId` and records the +currently active account alongside it. An `init` that only attaches a second +worktree to a project that already exists reads nothing — that project's link is +already decided, and re-reading this checkout's file could silently replace an +env map somebody set by hand. `pb env link` sets or replaces the same thing +deliberately, and `--map dev=development,production=prod` handles the projects +whose remote spells an environment differently — patchbay's name is what the +vault records, the remote's name is what goes on the command line. `--domain` is +for self-hosted and EU instances. Two failure modes get their own answers rather than a stack trace: no login at all points at `infisical login`, and a missing CLI points at `pb env import`, @@ -193,7 +271,9 @@ Once the values are in, delete the file. That is the point of the exercise. ### The commands ``` -pb env init [--id ] [--dir ] [--default-env ] +pb env init [--id ] [--dir ] [--default-env ] [--no-marker] +pb env attach [--dir ] +pb env detach [--dir ] pb env link --project-id [--project ] [--account ] [--domain ] [--map dev=development,...] pb env projects [--json] @@ -208,21 +288,52 @@ pb env export [-e ] [--project ] [--format dotenv|json] pb env forget [--project ] [--yes] ``` +`pb env init` does three things: register the project (under `--id`, else the +name the directory's own marker claims, else the directory name as a slug), +attach this directory to it, and write the marker. Run in a worktree or a second +clone of a project this machine already knows, it attaches that directory +instead of failing on the duplicate id. Run in a fresh clone whose marker names +a project the registry lacks, it registers the project the repo names — which is +the case that makes `git clone && pb env init` work on a machine you have not +copied `projects.json` to yet. An `--id` that disagrees with a marker already in +the directory is refused before anything is registered, since that is a +directory being pulled in two directions; `--no-marker` is the way through, and +the attachment it makes beats the marker anyway. + +`pb env projects --json` prints the portable manifest's own shape and nothing +else: this machine's attachments live in another file for a reason, and folding +them in would produce JSON that cannot be copied to the next laptop. The plain +table folds them into a ROOTS column, and names any attachment whose project is +not registered here in a footer, since that is the one thing a column cannot +show. + `pb env set` takes the value from stdin or a hidden prompt, never from argv — the same rule as `pb key add`, for the same reason. `pb env forget` removes the -project from the registry and deletes every Keychain blob behind it, both -layers of every environment. It revokes nothing: a credential that was in there -keeps working until you rotate it at its provider. +project from the registry, drops this machine's attachments to it, and deletes +every Keychain blob behind it, both layers of every environment. It revokes +nothing: a credential that was in there keeps working until you rotate it at its +provider. It also touches no repository — a committed marker is left exactly +where it is, and the command says so: + +``` + a committed .patchbay.toml is untouched: run `rm .patchbay.toml` in the repo + if it should stop claiming `pathors` +``` ### AI agents | Tool | What it does | Gate | |---|---|---| -| `list_env_projects` | registered projects, their roots, environments, sync config | open — metadata only | +| `list_env_projects` | registered projects, their environments and sync config, plus this machine's attached `roots` | open — metadata only | | `list_env_vars` | names and provenance for one environment | open — metadata only | | `pull_env` | refreshes the synced layer | open — it executes the Infisical CLI and hits the network, but the outcome it returns carries counts and names, no values | | `set_env_var` | writes one variable into the local layer | open, like `store_key` — an agent that creates a project credential should register it, so the machine keeps knowing | +`roots` there is this machine's attachments and not the project's home, which +the tool's own description spells out: an empty list is the normal state for a +project resolved by its marker, or one that arrived in a copied `projects.json`, +so a path in that field is never proof of where you are working. + **No tool reads a value back.** Not gated behind `PATCHBAY_ALLOW_SECRET_READ`, like the key vault's `get_key` — absent. An environment is dozens of values at once, which makes it the single worst thing to hand an agent by accident, and @@ -244,8 +355,18 @@ security find-generic-password -s patchbay -a env:pathors/dev/local ``` Variable names, provenance and the last-pull timestamp go to -`~/.config/patchbay/projects.json`, mode `0600`, written atomically. Names are -not secret, but which of them a machine holds is nobody else's business. +`~/.config/patchbay/projects.json`, mode `0600`, written atomically. Which +directories on this machine map to which project go to `attachments.json`, the +same way. Names are not secret and neither is a directory path, but which of +them a machine holds is nobody else's business. + +The `.patchbay.toml` marker is the one file that gets none of that treatment: it +is written with ordinary permissions, because it holds a project *name*, it is +meant to be committed, and a `0600` file in a repo would only confuse the next +person to `ls -l` it. Re-pointing an existing marker at a different project is +refused — that changes what every checkout of the repo resolves to, so it should +be a deliberate edit, not the side effect of running a command in the wrong +directory. **No last4.** The key vault records the last four characters of a value as a recognition aid. Env vars get none, because half of these values are `true`, @@ -263,7 +384,11 @@ found by hand. **A malformed registry is a hard error**, never an empty one. Starting over silently would let the next write drop every project on the machine and orphan every Keychain item behind them. So is a `projects.json` written by a newer -patchbay than the one you are running. +patchbay than the one you are running. `attachments.json` follows the same +discipline — missing is empty, malformed names the file, a newer version is +refused rather than rewritten — and carries its own schema version, because the +two files have different lifetimes: one is copied between machines, the other is +rebuilt on each. **Failures say nothing.** A failed `infisical export` is reported from its stderr only — on a partial export or a broken pipe, stdout can already hold @@ -285,9 +410,22 @@ picked, into a destination you named — but the vault's guarantee ends at the redirect. `pb env run` gives up nothing, and is the reason `export` does not have to be convenient. -**Symlinked checkouts need `--project`.** Directory resolution does not -canonicalize, deliberately (see above), so a path that reaches the repo through -a symlink is not recognised as inside it. +**A committed marker means repo content selects the project.** Stated in full +above: cloning a repo whose `.patchbay.toml` names `pathors` makes that +project's variables available in it. The bounds are that a marker can only name +a project you already registered, and that an attachment always overrides it. +`pb env init --no-marker` plus `pb env attach` is the way to work from checkouts +you do not trust. + +**Symlinked paths do not match an attachment.** Roots are compared by plain path +prefix with no canonicalization, deliberately: resolving symlinks would make the +answer depend on the filesystem's mood, and `/tmp` on macOS is itself a symlink. +A checkout reached through a symlinked path is therefore not recognised as +inside its attached root — commit a marker, which is found by walking up +whatever path you actually used, or pass `--project `. The same exactness +applies to `pb env detach`: it matches the root as it was recorded, so +`/tmp/work` and `/private/tmp/work` are two different roots, and detaching needs +the spelling that attached. **One provider.** `infisical` is the only thing `pull` knows, and `pb env link` refuses anything else by name rather than failing later. Everything else on the From 03bd04bc72a026166f954f036b8d31e4a8c858c5 Mon Sep 17 00:00:00 2001 From: YJack0000 Date: Fri, 14 Aug 2026 00:54:13 +0800 Subject: [PATCH 3/3] [chore] smoke: expected tool set includes the env vault tools --- crates/patchbay-mcp/smoke.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/patchbay-mcp/smoke.sh b/crates/patchbay-mcp/smoke.sh index e9aebc9..803699d 100755 --- a/crates/patchbay-mcp/smoke.sh +++ b/crates/patchbay-mcp/smoke.sh @@ -115,7 +115,8 @@ names = {t["name"] for t in tl["result"]["tools"]} expected = {"list_connections", "get_status", "switch_profile", "verify", "get_permissions", "store_key", "list_keys", "get_key", "remove_key", "verify_key", "list_mcp_clients", "add_mcp_server", "copy_mcp_server", "remove_mcp_server", - "check_updates", "plan_setup", "mark_setup_done"} + "check_updates", "plan_setup", "mark_setup_done", + "list_env_projects", "list_env_vars", "pull_env", "set_env_var"} need(names == expected, f"tool set mismatch: {sorted(names)}") for t in tl["result"]["tools"]: need(t.get("description"), f"{t['name']} has no description")