diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f0035d..ad65028 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -100,6 +100,73 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 values, since those fields routinely hold API keys. A `copy` does carry values (a server that cannot authenticate is useless) and names what travelled. +- **The project env vault** ([`pb env`](docs/env-vault.md)) — the variables a + *project* needs, held the way the key vault holds credentials: names and + provenance in `~/.config/patchbay/projects.json` (`0600`), values in the macOS + Keychain, and no plaintext `.env` anywhere. A project is a portable **name**, + not a path: the manifest holds ids, environments and sync config and no + absolute path at all, so copying it to another machine is the supported way to + take your projects with you. Each of its environments has two layers: `synced`, + which `pb env pull` replaces wholesale from Infisical, and `local`, which you + set by hand and which wins on merge. Those are `.env.local` semantics, and + they only hold because **patchbay never pushes** — there is no code path that + writes a variable to a remote, so a local override is invisible to the cloud + by construction rather than by policy, and a pull can never carry your + container's `DATABASE_URL` into the team's shared set. Values are stored one + Keychain item per project × environment × layer (account + `env://`), 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. +- **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`. `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`. `list_env_projects` reports each project's machine-local + `roots` alongside its environments, and says what an empty list means, so an + agent does not read a path there as where the user is working. The first two + are metadata only; `pull_env` executes the Infisical CLI but its outcome + carries counts and names, not values, so it is ungated; + `set_env_var` is open like `store_key`, so an agent that creates a + project credential registers it. Nothing reads a value back — not even behind + `PATCHBAY_ALLOW_SECRET_READ`. An environment is dozens of secrets at once, + and `pb env run` in your own terminal is the answer instead. + ### Changed - `patchbay_core::util` now owns the write-safety machinery MCP client diff --git a/README.md b/README.md index b83ac44..d9ee2f0 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 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. @@ -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..9c618c2 --- /dev/null +++ b/crates/patchbay-cli/src/env.rs @@ -0,0 +1,1969 @@ +//! `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, 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; + +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. + /// + /// 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 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. + #[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, + /// 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 { + /// 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, + no_marker, + } => { + let root = absolute_dir(dir)?; + let done = init(®istry, &root, id.as_deref(), &default_env, !no_marker)?; + + 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()); + + 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) + } + + 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()?; + // 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: 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); + } + 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, &roots, 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); + } + // 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); + 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) + } + } +} + +// --------------------------------------------------------------------------- +// 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. 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 +/// 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, 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(()); + }; + 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 ROOTS takes whatever the other columns +/// leave and gets truncated into it. +/// +/// `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(), + }; + 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("ROOTS", 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(&roots_cell(project, root_w), 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'); + } + // 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 +} + +/// 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"), + dir.path().join("attachments.json"), + Box::new(MemoryKeystore::new()), + ); + (dir, registry) + } + + /// One project attached to one directory, one environment, both layers, + /// one override. + fn seeded(registry: &EnvRegistry) { + registry.register("pathors", "dev").unwrap(); + registry.attach("/repos/pathors", "pathors").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(); + } + + /// 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(), + source, + } + } + + // --- tables ------------------------------------------------------------- + + #[test] + fn test_projects_table_is_plain_and_aligned_without_color() { + let (_dir, registry) = vault(); + seeded(®istry); + registry.register("side", "dev").unwrap(); + registry.attach("/repos/side-project", "side").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, &roots_of(®istry), &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("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(); + 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}"); + } + + // --- 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(); + 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..79bbb33 --- /dev/null +++ b/crates/patchbay-core/src/env_sync.rs @@ -0,0 +1,617 @@ +//! 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"), + dir.path().join("attachments.json"), + Box::new(Shared(store.clone())), + ); + registry.register("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"), + dir.path().join("attachments.json"), + Box::new(MemoryKeystore::new()), + ); + registry.register("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..7981eb7 --- /dev/null +++ b/crates/patchbay-core/src/envs.rs @@ -0,0 +1,2738 @@ +//! 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 +//! 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. +//! +//! **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 +//! 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 `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; + +/// 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 +// --------------------------------------------------------------------------- + +/// 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. +/// +/// **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, + /// 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, +} + +/// 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 { + version: u32, + 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 portable metadata file, a machine-local attachment file, +/// and a [`Keystore`] for the values. +/// +/// 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 explicit file locations and a keystore. Tests use this with a + /// tempdir and [`crate::keystore::MemoryKeystore`]. + 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 locations [`Paths`] reports, with the given keystore. + pub fn with_paths(paths: &Paths, store: Box) -> Self { + Self::new(paths.projects_file(), paths.attachments_file(), store) + } + + /// 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( + &paths, + Box::new(SecurityCliKeystore::new()), + )) + } + + /// 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() + } + + // --- 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)) + } + + /// 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. + /// + /// 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 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; + }; + 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(None) + } + + /// 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 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 mut file = self.load()?; + if file.projects.iter().any(|p| p.id == id) { + anyhow::bail!( + "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(), + 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) + } + + /// 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" { + 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: this machine's attachments to it, the metadata + /// entry, and every stored value for every environment, both layers. + /// + /// **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 + .projects + .iter() + .position(|p| p.id == id) + .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() { + 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> { + read_text(&self.path) + } + + 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(), + })?; + write_atomic(&self.path, &body) + } + + fn restore(&self, previous: Option<&str>) -> anyhow::Result<()> { + match previous { + 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(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(anyhow::anyhow!( + "could not remove {}: {e}", + self.path.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()); + } + 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) + } +} + +// --------------------------------------------------------------------------- +// 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() + .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, + registry, + store, + } + } + + fn vault() -> Vault { + 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 + .attach("/Users/x/repos/pathors", "pathors") + .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", "dev").unwrap(); + + assert_eq!(entry.id, "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; 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] + 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.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_names_the_conflict() { + let v = vault(); + v.registry.register("pathors", "dev").unwrap(); + + let err = v + .registry + .register("pathors", "dev") + .unwrap_err() + .to_string(); + assert!(err.contains("already registered as `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 + .attach("/repos/pathors", "other") + .unwrap_err() + .to_string(); + assert!( + err.contains("already attached to project `pathors`"), + "{err}" + ); + assert!(err.contains("`other`"), "{err}"); + assert!(err.contains("pb env detach"), "{err}"); + + // 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_attaching_to_an_unknown_project_points_at_init() { + let v = vault(); + let err = v + .registry + .attach("/repos/ghost", "ghost") + .unwrap_err() + .to_string(); + 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 + .detach(Path::new("/repos/nowhere")) + .unwrap_err() + .to_string(); + assert!( + err.contains("nothing is attached at /repos/nowhere"), + "{err}" + ); + assert!(err.contains("pb env projects"), "{err}"); + } + + #[test] + fn test_every_worktree_of_a_repo_shares_one_project() { + let v = vault(); + v.registry.register("pathors", "dev").unwrap(); + v.registry.attach("/repos/pathors", "pathors").unwrap(); + v.registry + .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)) + .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); + // 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(); + 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.registry.attachments().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_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 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 = 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", "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", "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(), + v.registry.attachments_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", "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", "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(), + 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(); + 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", "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", "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 = registry_at(dir.path(), Box::new(Shared(store.clone()))); + ok.register("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 = registry_at(dir.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 = 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"), + "{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 = 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` — + // 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( + &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 = 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 — 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(); + 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..0d2a63c 100644 --- a/crates/patchbay-core/src/lib.rs +++ b/crates/patchbay-core/src/lib.rs @@ -25,9 +25,18 @@ //! 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. +//! 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; +pub mod envs; pub mod keys; pub mod keys_verify; pub mod keystore; @@ -42,6 +51,11 @@ pub mod util; pub mod versions; pub use deprecations::{Advisory, AdvisoryKind}; +pub use env_sync::{pull, PullOutcome}; +pub use envs::{ + 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}; pub use keystore::Keystore; diff --git a/crates/patchbay-core/src/paths.rs b/crates/patchbay-core/src/paths.rs index 6a27f8f..2dd0f2d 100644 --- a/crates/patchbay-core/src/paths.rs +++ b/crates/patchbay-core/src/paths.rs @@ -647,6 +647,28 @@ 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`]). + /// + /// 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 { @@ -680,8 +702,21 @@ mod tests { p.keys_file(), PathBuf::from("/nowhere/.config/patchbay/keys.json") ); + assert_eq!( + 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-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/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") diff --git a/crates/patchbay-mcp/src/envs.rs b/crates/patchbay-mcp/src/envs.rs new file mode 100644 index 0000000..1470514 --- /dev/null +++ b/crates/patchbay-mcp/src/envs.rs @@ -0,0 +1,722 @@ +//! 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, 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 + // 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, + "roots": roots + .iter() + .map(|root| root.display().to_string()) + .collect::>(), + "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, 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 \ +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(); + // 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)), + } + } + + #[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; + + fn at(rfc: &str) -> DateTime { + DateTime::parse_from_rfc3339(rfc) + .unwrap() + .with_timezone(&Utc) + } + + fn project() -> ProjectEntry { + ProjectEntry { + id: "pathors".into(), + 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"); + // 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(); + 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_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"); + 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..a4b124c --- /dev/null +++ b/docs/env-vault.md @@ -0,0 +1,432 @@ +# 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 *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 +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 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 +``` + +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: + +``` +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 + +This is the whole point of the split. A new laptop is three steps: + +```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 +`--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 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`, +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 ] [--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] +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 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, 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 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 +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. 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`, +`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. `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 +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. + +**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 +machine arrives through `pb env import`.