diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c42076..3bf6b26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,35 @@ All notable changes to this project are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- **`pb env pull` can read a folder inside an Infisical project, not just its + root.** Infisical's secrets are a tree, and a project holding one folder per + service is the ordinary shape — but patchbay had no idea such a thing + existed and always exported from `/`. A pull aimed at the root of a project + that keeps everything under `/outbox` does not fail: it succeeds, returns + nothing, and reports `0 variables`, which reads exactly like a project nobody + has filled in yet. `pathorsAI/coldmail` could therefore not use the env vault + at all and ran `infisical run --path /outbox -- ` by hand. + + `pb env link --project-id --path /outbox` now pins the folder alongside + the account, `pb env pull` passes it to the CLI, and every place the sync + config is visible says which folder it is: the `secret path:` line under + `pb env link` and `pb env init`, the SYNC column of `pb env projects`, the + `secret_path` field on a pull's result, and `sync.secret_path` in the MCP + `list_env_projects`. A pull that comes back empty now names the folder it + read and the command that repoints it, rather than leaving `0` to be + interpreted. + + Nothing changes for a project that pulls from the root, which is still the + default and still adds no flag to the `infisical` command line. Registries + written by earlier versions have no such field and are read as `/`, so + `projects.json` needs no migration and no version bump — the folder inside + the remote is the same string on every machine, so it travels in the portable + manifest exactly like the remote project id beside it. + ## [0.4.1] - 2026-08-24 ### Fixed diff --git a/crates/patchbay-cli/src/env.rs b/crates/patchbay-cli/src/env.rs index 9c618c2..50d0ea6 100644 --- a/crates/patchbay-cli/src/env.rs +++ b/crates/patchbay-cli/src/env.rs @@ -21,7 +21,8 @@ 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, + EnvRegistry, EnvVarInfo, EnvVarSource, ProjectEntry, SyncConfig, DEFAULT_ENV, + DEFAULT_SECRET_PATH, MARKER_FILE, }; use patchbay_core::paths::Paths; use patchbay_core::probes::infisical; @@ -33,7 +34,11 @@ 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; +/// Wide enough for `infisical:` plus a work email plus a short secret path — +/// the SYNC cell's three parts, and the last one is the one a reader cannot +/// reconstruct from anywhere else. The column only grows to what the rows +/// need, so projects pulling from the root are no wider than they were. +const COL_SYNC_MAX: usize = 42; const COL_NAME_MAX: usize = 40; /// Wide enough for `local override`, the longest source label there is. const COL_SOURCE: usize = 14; @@ -105,6 +110,11 @@ pub enum Command { /// Account the pull must run as. Defaults to the active infisical login. #[arg(long, value_name = "EMAIL")] account: Option, + /// Folder inside the Infisical project to pull from: `--path /outbox`. + /// Defaults to `/`, the root — which holds nothing at all in a project + /// that keeps a folder per service. + #[arg(long, value_name = "PATH")] + path: Option, /// API base URL, for self-hosted or EU instances. #[arg(long, value_name = "URL")] domain: Option, @@ -316,6 +326,7 @@ pub fn run(command: Command, styles: &Styles) -> Result { project_id, project, account, + path, domain, map, } => { @@ -337,6 +348,11 @@ pub fn run(command: Command, styles: &Styles) -> Result { account, domain, env_map: parse_env_map(&map)?, + // `link` replaces the whole config, so an omitted --path + // means the root here rather than "keep the old folder" — + // the same rule --domain and --map have always followed. + // `set_sync` normalises the spelling. + secret_path: path.unwrap_or_else(|| DEFAULT_SECRET_PATH.to_string()), }, )?; @@ -410,6 +426,7 @@ pub fn run(command: Command, styles: &Styles) -> Result { outcome.env ); println!(" remote environment: {}", outcome.remote_env); + println!(" secret path: {}", outcome.secret_path); for note in &outcome.notes { println!(" note: {note}"); } @@ -834,6 +851,10 @@ fn print_adopted_sync(registry: &EnvRegistry, entry: &ProjectEntry, root: &Path) account, domain: None, env_map: BTreeMap::new(), + // `.infisical.json` records a workspace, never a folder + // inside it, so an adopted link reads the project root and + // `pb env link --path` is how it learns better. + secret_path: DEFAULT_SECRET_PATH.to_string(), }, )?; println!(" read {INFISICAL_FILE} in the project root"); @@ -880,6 +901,10 @@ fn print_sync(entry: &ProjectEntry) { }; println!(" sync: {} {}", sync.provider, sync.project_id); println!(" account: {}", sync.account); + // Always, including the root: this is the line that tells somebody who + // linked a project whose secrets live in a folder that they have just + // pointed patchbay at an empty one. + println!(" secret path: {}", sync.remote_path()); if let Some(domain) = &sync.domain { println!(" domain: {domain}"); } @@ -1079,54 +1104,93 @@ fn column_width(values: impl Iterator, header: &str, max: usize) - .max(header.len()) } +/// The ROOTS cell for one project: this machine's attachments for it, comma +/// joined while they fit and collapsed 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. +/// +/// The count is reserved out of `width` rather than left to the row's own +/// truncation, which would eat it and leave a bare `…` claiming nothing in +/// particular. +fn roots_cell(paths: Option<&[PathBuf]>, width: usize) -> String { + let paths = match paths { + 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; + } + let suffix = format!(" +{} more", shown.len() - 1); + let room = width.saturating_sub(suffix.chars().count()); + format!("{}{suffix}", render::truncate(&shown[0], room)) +} + +/// The SYNC cell at its natural width, which is also what the column is sized +/// against. +/// +/// A non-root secret path is shown because a reader cannot infer it and it +/// decides what a pull returns; the root is left off, since a column saying `/` +/// on every row would be pure noise. +fn sync_full(p: &ProjectEntry) -> String { + match &p.sync { + Some(sync) if !sync.is_root_path() => { + format!("{}:{} {}", sync.provider, sync.account, sync.remote_path()) + } + Some(sync) => format!("{}:{}", sync.provider, sync.account), + None => DASH.to_string(), + } +} + +/// The SYNC cell squeezed into `width`. The path suffix is reserved out of the +/// width rather than left to the row's own truncation — the same trick the +/// ROOTS column plays with `+N more`, and for the same reason: truncation eats +/// the end of the cell, which is exactly the part nobody could guess. +fn sync_cell(p: &ProjectEntry, width: usize) -> String { + let full = sync_full(p); + if full.chars().count() <= width { + return full; + } + match &p.sync { + Some(sync) if !sync.is_root_path() => { + let suffix = format!(" {}", sync.remote_path()); + let room = width.saturating_sub(suffix.chars().count()); + format!( + "{}{suffix}", + render::truncate(&format!("{}:{}", sync.provider, sync.account), room) + ) + } + _ => render::truncate(&full, width), + } +} + +/// The ENVS cell: the environment names, or a dash for a project that has none +/// registered yet. +fn envs_cell(p: &ProjectEntry) -> String { + let names = p.env_names(); + if names.is_empty() { + DASH.to_string() + } else { + names.join(",") + } +} + /// 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()), @@ -1139,7 +1203,7 @@ pub fn render_projects( COL_ENVS_MAX, ); let sync_w = column_width( - projects.iter().map(|p| sync_cell(p).chars().count()), + projects.iter().map(|p| sync_full(p).chars().count()), "SYNC", COL_SYNC_MAX, ); @@ -1162,11 +1226,14 @@ pub fn render_projects( 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), + &render::truncate( + &roots_cell(roots.get(&project.id).map(Vec::as_slice), 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); + let sync = 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) @@ -1403,6 +1470,7 @@ mod tests { account: "contact@pathors.com".into(), domain: None, env_map: BTreeMap::new(), + secret_path: DEFAULT_SECRET_PATH.into(), }, ) .unwrap(); @@ -1426,6 +1494,53 @@ mod tests { assert!(lines[2][col..].starts_with("/repos/side-project"), "{out}"); } + #[test] + fn test_the_sync_column_shows_a_secret_path_and_hides_the_root_one() { + let (_dir, registry) = vault(); + registry.register("coldmail", "dev").unwrap(); + registry.attach("/repos/coldmail", "coldmail").unwrap(); + registry.register("pathors", "dev").unwrap(); + registry.attach("/repos/pathors", "pathors").unwrap(); + let link = |id: &str, path: &str| { + registry + .set_sync( + id, + SyncConfig { + provider: "infisical".into(), + project_id: "3ab516bd".into(), + account: "contact@pathors.com".into(), + domain: None, + env_map: BTreeMap::new(), + secret_path: path.into(), + }, + ) + .unwrap(); + }; + link("coldmail", "/outbox"); + link("pathors", DEFAULT_SECRET_PATH); + + let out = render_projects( + ®istry.projects().unwrap(), + &roots_of(®istry), + &Styles::new(false), + ); + let lines: Vec<&str> = out.lines().collect(); + + // Which folder a project pulls from is the one thing in this row a + // reader cannot work out for themselves, so it is shown whole. + assert!(lines[1].starts_with("coldmail"), "{out}"); + assert!( + lines[1].contains("infisical:contact@pathors.com /outbox"), + "{out}" + ); + // And the default says nothing, because `/` on every row is noise. + assert!(lines[2].starts_with("pathors"), "{out}"); + assert!( + lines[2].trim_end().ends_with("contact@pathors.com"), + "{out}" + ); + } + #[test] fn test_the_roots_column_counts_worktrees_and_explains_a_dash() { let (_dir, registry) = vault(); diff --git a/crates/patchbay-core/src/env_sync.rs b/crates/patchbay-core/src/env_sync.rs index 79bbb33..03632f3 100644 --- a/crates/patchbay-core/src/env_sync.rs +++ b/crates/patchbay-core/src/env_sync.rs @@ -15,13 +15,20 @@ //! 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. +//! +//! **Where in the remote** is the other pinned coordinate. Infisical's secrets +//! are a tree, not a flat set, and a project holding one folder per service is +//! the ordinary shape — `pathorsAI/coldmail` keeps everything under `/outbox`. +//! A pull therefore reads [`crate::envs::SyncConfig::secret_path`] and passes +//! it to the CLI; the default `/` is the project root and the behaviour every +//! patchbay had before the field existed. use std::collections::BTreeMap; use chrono::Utc; use serde::{Deserialize, Serialize}; -use crate::envs::{validate_var_name, EnvRegistry, EnvVarSource, ProjectEntry}; +use crate::envs::{validate_var_name, EnvRegistry, EnvVarSource, ProjectEntry, SyncConfig}; use crate::paths::Paths; use crate::probes::infisical; @@ -37,6 +44,11 @@ pub struct PullOutcome { pub env: String, /// The remote's slug for it, which is not always the same thing. pub remote_env: String, + /// The folder inside the remote project this pull read, `/` for its root. + /// Reported on every pull, including the default one: "0 variables" and + /// "0 variables *from `/`*" are the same sentence until you know the + /// project keeps everything under `/outbox`. + pub secret_path: String, /// How many variables the synced layer now holds. pub count: usize, /// Local names that shadow a synced one, after this pull. @@ -56,13 +68,19 @@ struct RemoteSecret { 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 { +/// Everything that has to hold before a pull is worth a subprocess, in the +/// order a person can act on: is this project linked at all, is it linked to +/// something patchbay can read, is the machine-global login the right one, and +/// is the CLI even here. Each answer is a different command to type, so each +/// gets its own message rather than one "cannot pull" covering four causes. +/// +/// The account check comes before the binary check on purpose. Both are local, +/// so neither is cheaper; but a machine with the wrong login is the failure +/// this module exists for (see the module docs), and a person who has both +/// problems wants to hear about that one first. +/// +/// Returns the [`SyncConfig`] the caller would otherwise have to unwrap again. +fn preflight<'a>(paths: &Paths, project: &'a ProjectEntry) -> anyhow::Result<&'a SyncConfig> { let Some(sync) = &project.sync else { anyhow::bail!( "no sync configured for `{}`; link it with `pb env link --project-id Vec { let mut args: Vec = vec![ "export".into(), "--projectId".into(), sync.project_id.clone(), "--env".into(), - remote_env.clone(), + remote_env.into(), "--format".into(), "json".into(), // Without it the CLI decorates stdout with its own banner, and stdout // has to stay parseable JSON. "--silent".into(), ]; + // `--path` only when it says something. `infisical export` defaults to the + // project root, so passing `--path /` would change no result on any CLI + // that has the flag while breaking every CLI old enough not to — and a + // subprocess should not carry an argument whose only effect is to narrow + // the versions it runs under. + if secret_path != crate::envs::DEFAULT_SECRET_PATH { + args.push("--path".into()); + args.push(secret_path.into()); + } if let Some(domain) = &sync.domain { args.push("--domain".into()); args.push(domain.clone()); } + args +} + +/// The remote's array folded into the map a synced layer is made of, plus the +/// notes explaining what the fold lost. +/// +/// Both anomalies are recorded rather than raised. A remote is a shared thing: +/// one name the shell could not export, or one key someone entered twice, must +/// not stop everyone else's pull — but it does have to be visible, because the +/// map alone cannot show that it once held something else. +fn index_secrets(secrets: Vec) -> (BTreeMap, Vec) { + let mut notes = Vec::new(); + let mut vars: BTreeMap = BTreeMap::new(); + let mut duplicated: Vec = Vec::new(); + for secret in secrets { + 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(", ") + )); + } + (vars, notes) +} + +/// 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 sync = preflight(paths, project)?; + + let remote_env = sync.remote_env(env); + let secret_path = sync.remote_path(); + let args = export_args(sync, &remote_env, &secret_path); let argv: Vec<&str> = args.iter().map(String::as_str).collect(); let out = paths.run_env("infisical", &argv, &[])?; @@ -137,7 +221,8 @@ pub fn pull( )); } anyhow::bail!( - "`infisical export` failed for `{}/{env}` (remote environment `{remote_env}`): {detail}", + "`infisical export` failed for `{}/{env}` (remote environment `{remote_env}`, secret \ + path `{secret_path}`): {detail}", project.id ); } @@ -150,34 +235,28 @@ pub fn pull( ) })?; - 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() { + let (vars, mut notes) = index_secrets(secrets); + + let count = vars.len(); + // An empty answer is a successful export of a folder that holds nothing, + // and the likeliest reason is looking in the wrong one: Infisical's secrets + // are a tree, and a project that keeps everything under `/outbox` answers + // `/` with exactly this — no error, no secrets. Saying so here is the + // difference between a one-line fix and an afternoon of "the pull works + // but the app still has no DATABASE_URL". + if count == 0 { + let advice = if secret_path == crate::envs::DEFAULT_SECRET_PATH { + "that is the project root, and a project that keeps its secrets in a folder answers \ + it with exactly this" + } else { + "that folder is empty, or spelled differently in the remote" + }; notes.push(format!( - "the remote returned {} more than once; the last value won", - duplicated - .iter() - .map(|k| format!("`{k}`")) - .collect::>() - .join(", ") + "the remote returned nothing under `{secret_path}` of `{remote_env}`: {advice}; \ + `pb env link --project-id {} --path /` points the pull somewhere else", + sync.project_id )); } - - let count = vars.len(); registry.replace_synced(&project.id, env, vars, Utc::now())?; let overridden: Vec = registry @@ -202,6 +281,7 @@ pub fn pull( Ok(PullOutcome { env: env.to_string(), remote_env, + secret_path, count, overridden, notes, @@ -325,6 +405,7 @@ mod tests { account: account.into(), domain: None, env_map: BTreeMap::new(), + secret_path: crate::envs::DEFAULT_SECRET_PATH.into(), } } @@ -339,6 +420,9 @@ mod tests { let outcome = pull(&rig.paths, &rig.registry, &project, "dev").unwrap(); assert_eq!(outcome.env, "dev"); assert_eq!(outcome.remote_env, "dev"); + // The default is the project root, and it is reported even though it + // was never typed. + assert_eq!(outcome.secret_path, "/"); assert_eq!(outcome.count, 2); assert!(outcome.overridden.is_empty()); assert!(outcome.notes.is_empty(), "{:?}", outcome.notes); @@ -356,7 +440,9 @@ mod tests { "--format", "json", "--silent", - ] + ], + "the root path must add no flag: it changes no result, and only \ + narrows the infisical versions this runs under" ); assert_eq!( @@ -415,6 +501,55 @@ mod tests { .is_some()); } + #[test] + fn test_a_secret_path_reaches_the_command_line_and_the_outcome() { + let rig = rig( + Some("contact@pathors.com"), + FakeExec::new().on("export", true, EXPORT, ""), + ); + let mut sync = sync_for("contact@pathors.com"); + // As a person would type it, rather than as the registry stores it. + sync.secret_path = "outbox/".into(); + let project = rig.link(sync); + + let outcome = pull(&rig.paths, &rig.registry, &project, "dev").unwrap(); + assert_eq!(outcome.secret_path, "/outbox"); + assert_eq!(outcome.count, 2); + + let line = rig.exec.last().unwrap().line(); + assert!(line.contains("--path /outbox"), "{line}"); + // The path is a coordinate in the remote and nothing else changes with + // it: the environment is still patchbay's own name for it. + assert!(line.contains("--env dev"), "{line}"); + + // `set_sync` stored one spelling, so the registry cannot end up with + // two entries pulling the same folder. + let stored = rig.registry.get("pathors").unwrap().unwrap(); + assert_eq!(stored.sync.unwrap().secret_path, "/outbox"); + } + + #[test] + fn test_an_empty_folder_says_which_one_it_read() { + let rig = rig( + Some("contact@pathors.com"), + FakeExec::new().on("export", true, "[]", ""), + ); + let project = rig.link(sync_for("contact@pathors.com")); + + // The coldmail case in miniature: the export succeeds, the folder is + // simply the wrong one, and a bare "pulled 0 variables" would look like + // an empty project rather than a misdirected pull. + let outcome = pull(&rig.paths, &rig.registry, &project, "dev").unwrap(); + assert_eq!(outcome.count, 0); + assert!( + outcome.notes.iter().any(|n| n.contains("under `/`") + && n.contains("pb env link") + && n.contains("--path /")), + "{:?}", + outcome.notes + ); + } + #[test] fn test_the_wrong_active_account_refuses_before_spending_a_subprocess() { let rig = rig( diff --git a/crates/patchbay-core/src/envs.rs b/crates/patchbay-core/src/envs.rs index 8569dd6..1b5325d 100644 --- a/crates/patchbay-core/src/envs.rs +++ b/crates/patchbay-core/src/envs.rs @@ -14,6 +14,13 @@ //! 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 string in here looks like a path and is not one: +//! [`SyncConfig::secret_path`], the folder *inside the Infisical project* a +//! pull reads. It names nothing on this machine and is identical for everyone +//! on the team, so it travels exactly as well as the remote project id beside +//! it. The rule above is about local directories; do not read it as a ban on +//! anything containing a slash. +//! //! 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 @@ -87,6 +94,10 @@ pub const DEFAULT_ENV: &str = "dev"; /// directory root. See [`read_marker`] and [`EnvRegistry::find_by_dir`]. pub const MARKER_FILE: &str = ".patchbay.toml"; +/// The folder inside the *remote* a pull reads when nobody says otherwise: the +/// Infisical project's root. See [`SyncConfig::secret_path`]. +pub const DEFAULT_SECRET_PATH: &str = "/"; + // --------------------------------------------------------------------------- // validation // --------------------------------------------------------------------------- @@ -128,6 +139,34 @@ pub fn validate_var_name(name: &str) -> anyhow::Result<()> { Ok(()) } +/// An Infisical secret path, in the one spelling patchbay stores: a leading +/// slash, no trailing one, `/` for the root. +/// +/// This is **not** a filesystem path and is never checked against one — it is a +/// folder *inside the remote project*, which is why it is normalised by string +/// surgery rather than by [`std::path`]. See [`SyncConfig::secret_path`] for the +/// distinction this module works hard not to blur. +/// +/// Empty, `""`, `"/"` and `"//"` all mean the root, because a user who passes +/// `--path ""` means "no subfolder" rather than "a folder with no name". A +/// non-root path is stored `/like/this` so that two spellings of the same +/// folder — `outbox`, `/outbox/` — can never produce two registry entries that +/// pull the same secrets while looking different in `pb env projects`. +pub fn normalize_secret_path(raw: &str) -> String { + let trimmed = raw.trim(); + let inner = trimmed.trim_matches('/'); + if inner.is_empty() { + return DEFAULT_SECRET_PATH.to_string(); + } + format!("/{inner}") +} + +/// serde's `default` for [`SyncConfig::secret_path`]: the root, which is what +/// every registry written before the field existed meant. +fn default_secret_path() -> String { + DEFAULT_SECRET_PATH.to_string() +} + // --------------------------------------------------------------------------- // model // --------------------------------------------------------------------------- @@ -200,6 +239,30 @@ pub struct SyncConfig { /// itself. #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub env_map: BTreeMap, + /// Which folder **inside the Infisical project** a pull reads, e.g. + /// `/outbox`. `/` — the project root — is the default and was patchbay's + /// only behaviour before this field existed. + /// + /// **This is not a filesystem path**, and it is not the exception to the + /// "a project is a name, not a path" rule this module's header states. That + /// rule is about *this machine's* directories: `projects.json` records no + /// `/Users/you/repos/x`, because such a file could not be copied to another + /// laptop. A secret path is a coordinate inside the remote — the same + /// string for everyone on the team, on every machine — so it is exactly as + /// portable as `project_id` next to it, and belongs in the same file. + /// + /// Infisical's secrets are a tree, and one project holding a folder per + /// service is the shape teams actually use: `pathorsAI/coldmail` keeps its + /// variables under `/outbox`, so a pull from `/` returns nothing at all and + /// says so with an empty, entirely truthful "pulled 0 variables". + /// + /// Old registries have no such key at all, so it deserialises to `/` rather + /// than failing the whole file — which is why + /// [`PROJECTS_FILE_VERSION`] does not move for it: a patchbay from before + /// this field reads a file that carries it just as happily, because nothing + /// here is `deny_unknown_fields`. + #[serde(default = "default_secret_path")] + pub secret_path: String, } impl SyncConfig { @@ -210,6 +273,23 @@ impl SyncConfig { .cloned() .unwrap_or_else(|| env.to_string()) } + + /// The secret path in its stored spelling, normalised again on the way out. + /// + /// [`EnvRegistry::set_sync`] already normalises what it writes, so this is + /// a second pass over a value that should need none. It is here because + /// `projects.json` is a file people hand-edit and hand-copy between + /// machines, and a stray `outbox` or `/outbox/` typed into it should pull + /// the right folder rather than send a shape the remote never matches. + pub fn remote_path(&self) -> String { + normalize_secret_path(&self.secret_path) + } + + /// Whether the pull reads the project root, i.e. whether the path is worth + /// a reader's attention at all. + pub fn is_root_path(&self) -> bool { + self.remote_path() == DEFAULT_SECRET_PATH + } } /// One registered project. @@ -801,7 +881,10 @@ impl EnvRegistry { } /// Point a project at a remote, replacing whatever it was linked to. - pub fn set_sync(&self, id: &str, sync: SyncConfig) -> anyhow::Result { + /// + /// The secret path is normalised on the way in, so the registry holds one + /// spelling of a folder rather than the four a person might type. + pub fn set_sync(&self, id: &str, mut sync: SyncConfig) -> anyhow::Result { if sync.provider != "infisical" { anyhow::bail!( "`{}` is not a sync provider patchbay knows; the only one today is `infisical`", @@ -811,6 +894,7 @@ impl EnvRegistry { for env in sync.env_map.keys() { validate_env_name(env)?; } + sync.secret_path = normalize_secret_path(&sync.secret_path); let mut file = self.load()?; let project = project_mut(&mut file, id)?; @@ -2155,6 +2239,7 @@ mod tests { env_map: [("production".to_string(), "prod".to_string())] .into_iter() .collect(), + secret_path: DEFAULT_SECRET_PATH.into(), }, ) .unwrap(); @@ -2173,6 +2258,7 @@ mod tests { account: "a@b.com".into(), domain: None, env_map: BTreeMap::new(), + secret_path: DEFAULT_SECRET_PATH.into(), }, ) .unwrap_err() @@ -2182,6 +2268,98 @@ mod tests { assert!(v.registry.get("pathors").unwrap().unwrap().sync.is_some()); } + #[test] + fn test_a_secret_path_is_stored_in_one_spelling() { + for typed in ["/outbox", "outbox", "outbox/", "/outbox/", " /outbox "] { + assert_eq!(normalize_secret_path(typed), "/outbox", "{typed}"); + } + // Everything that means "no folder" means the root, including the + // empty string a shell hands over for `--path ""`. + for typed in ["", "/", "//", " "] { + assert_eq!(normalize_secret_path(typed), DEFAULT_SECRET_PATH, "{typed}"); + } + // Nesting survives; only the ends are patchbay's business. + assert_eq!(normalize_secret_path("outbox/worker/"), "/outbox/worker"); + + // And the registry normalises on the way in, so two spellings of one + // folder cannot produce two entries that look different in + // `pb env projects` while pulling the same secrets. + let v = vault(); + v.registry.register("pathors", "dev").unwrap(); + let entry = v + .registry + .set_sync( + "pathors", + SyncConfig { + provider: "infisical".into(), + project_id: "3ab516bd".into(), + account: "contact@pathors.com".into(), + domain: None, + env_map: BTreeMap::new(), + secret_path: "outbox/".into(), + }, + ) + .unwrap(); + let sync = entry.sync.unwrap(); + assert_eq!(sync.secret_path, "/outbox"); + assert_eq!(sync.remote_path(), "/outbox"); + assert!(!sync.is_root_path()); + assert!(std::fs::read_to_string(v.registry.path()) + .unwrap() + .contains("/outbox")); + } + + #[test] + fn test_a_registry_written_before_secret_paths_existed_still_loads() { + // Byte-for-byte what patchbay 0.4 wrote: a `sync` block with no + // `secret_path` key at all, and the schema version it has always + // carried. Adding a field must not turn every existing machine's + // projects.json into a hard error — which is what the version guard + // would do if the field were required, and what a bump would announce + // for a change that is compatible in both directions. + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("projects.json"), + r#"{ + "version": 1, + "projects": [ + { + "id": "coldmail", + "default_env": "dev", + "created_at": "2026-08-01T00:00:00Z", + "environments": { + "dev": { "synced_names": ["API_KEY"], "local_names": [], "synced_at": null } + }, + "sync": { + "provider": "infisical", + "project_id": "3ab516bd-248c-4be7-8f1a-bda73fe69d50", + "account": "contact@pathors.com" + } + } + ] +}"#, + ) + .unwrap(); + let registry = registry_at(dir.path(), Box::new(MemoryKeystore::new())); + + let entry = registry.get("coldmail").unwrap().unwrap(); + let sync = entry.sync.clone().unwrap(); + assert_eq!(sync.project_id, "3ab516bd-248c-4be7-8f1a-bda73fe69d50"); + // The absent key means what it has always meant: the project root, so + // an upgraded patchbay pulls exactly what the old one did. + assert_eq!(sync.secret_path, DEFAULT_SECRET_PATH); + assert!(sync.is_root_path()); + assert_eq!(entry.env("dev").unwrap().synced_names, vec!["API_KEY"]); + + // And a write from the new build fills the field in rather than + // leaving the next reader to guess. + registry + .set_local("coldmail", "dev", "LOCAL_ONLY", "1") + .unwrap(); + let raw = std::fs::read_to_string(registry.path()).unwrap(); + assert!(raw.contains("\"secret_path\""), "{raw}"); + } + // --- layers ------------------------------------------------------------- #[test] diff --git a/crates/patchbay-core/src/migrate/export.rs b/crates/patchbay-core/src/migrate/export.rs index e393511..7cc42c0 100644 --- a/crates/patchbay-core/src/migrate/export.rs +++ b/crates/patchbay-core/src/migrate/export.rs @@ -913,6 +913,7 @@ pub(crate) mod tests { account: "me@work.com".into(), domain: None, env_map: Default::default(), + secret_path: crate::envs::DEFAULT_SECRET_PATH.into(), }, ) .unwrap(); diff --git a/crates/patchbay-core/src/migrate/import.rs b/crates/patchbay-core/src/migrate/import.rs index 2f1d6de..71a30b3 100644 --- a/crates/patchbay-core/src/migrate/import.rs +++ b/crates/patchbay-core/src/migrate/import.rs @@ -798,6 +798,7 @@ mod tests { account: "me@home.com".into(), domain: None, env_map: Default::default(), + secret_path: crate::envs::DEFAULT_SECRET_PATH.into(), }, ) .unwrap(); diff --git a/crates/patchbay-mcp/src/envs.rs b/crates/patchbay-mcp/src/envs.rs index 1470514..98d280e 100644 --- a/crates/patchbay-mcp/src/envs.rs +++ b/crates/patchbay-mcp/src/envs.rs @@ -207,10 +207,18 @@ the distinct names a consumer would see (a local override shares its name with t 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 \ +env_map, secret_path } — 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. +- `secret_path` is WHICH FOLDER INSIDE THE REMOTE PROJECT a pull reads: Infisical's secrets are a \ +tree, and a project keeping one folder per service is ordinary. '/' is the root and the default. \ +It is NOT a directory on this machine and has nothing to do with `roots` — it is the same string \ +for every teammate on every laptop. Read it before explaining an empty environment: a pull from \ +the wrong folder succeeds, returns nothing and reports no error, so 'the project has no variables' \ +and 'patchbay is looking in the wrong place' are the same evidence until you check this field. \ +Changing it is `pb env link --project-id --path /` in a terminal; no tool here \ +writes it. - 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 { @@ -315,10 +323,16 @@ 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[] }. +Returns { project, env, remote_env, secret_path, 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). +- `secret_path` is the folder INSIDE the remote project this pull actually read, '/' being its \ +root. Say it out loud whenever the count surprises the user: an export from a folder that holds \ +nothing succeeds and returns zero variables, so a pull pointed at '/' on a project that keeps its \ +secrets under '/outbox' looks exactly like a project with no variables at all. The fix is \ +`pb env link --project-id --path /` in a terminal — patchbay's own `notes[]` will \ +have said so too. - `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 \ @@ -552,6 +566,7 @@ mod tests { env_map: [("production".to_string(), "prod".to_string())] .into_iter() .collect(), + secret_path: "/outbox".into(), }); let sync = described(&p)["sync"].clone(); @@ -562,6 +577,9 @@ mod tests { assert_eq!(sync["account"], "contact@pathors.com"); assert_eq!(sync["domain"], "https://eu.infisical.com/api"); assert_eq!(sync["env_map"]["production"], "prod"); + // Which folder of the remote the pull reads. Without it an agent + // cannot tell an empty project from one being read in the wrong place. + assert_eq!(sync["secret_path"], "/outbox"); } #[test] @@ -686,6 +704,31 @@ mod tests { ); } + #[test] + fn test_both_tools_explain_the_secret_path_and_that_it_is_not_a_directory() { + // The confusion worth pre-empting: `secret_path` and `roots` are both + // slash-separated strings on the same object, and only one of them is + // a place on this machine. + let listed = description("list_env_projects"); + assert!(listed.contains("secret_path"), "{listed}"); + assert!( + listed.contains("WHICH FOLDER INSIDE THE REMOTE PROJECT"), + "{listed}" + ); + assert!( + listed.contains("NOT a directory on this machine"), + "{listed}" + ); + assert!(listed.contains("--path /"), "{listed}"); + + // And on a pull, the failure it explains: an empty answer that is not + // an error. + let pulled = description("pull_env"); + assert!(pulled.contains("secret_path"), "{pulled}"); + assert!(pulled.contains("returns zero variables"), "{pulled}"); + assert!(pulled.contains("--path /"), "{pulled}"); + } + #[test] fn test_pull_env_advertises_its_cost_and_the_account_refusal() { let text = description("pull_env"); diff --git a/docs/env-vault.md b/docs/env-vault.md index 40c96be..58ea94a 100644 --- a/docs/env-vault.md +++ b/docs/env-vault.md @@ -32,7 +32,9 @@ 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. +manifest that cannot travel. (One string in there does look like a path and is +not one: the [secret path](#which-folder-of-the-remote) names a folder *inside +Infisical*, is identical on every machine, and travels with the rest.) A directory resolves to a project two ways, in this order: @@ -208,7 +210,9 @@ 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 +currently active account alongside it. That file names a workspace and never a +folder inside one, so an adopted link always starts at the root; `pb env link +--path` is how it learns better. 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 @@ -228,6 +232,52 @@ 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. +### Which folder of the remote + +Infisical's secrets are a **tree**, not a flat set. One project routinely holds +a folder per service — `/outbox`, `/worker`, `/web` — and a pull that reads the +wrong one does not fail. It succeeds, returns nothing, and reports `0 variables` +with a completely straight face, which is indistinguishable from a project +nobody has put anything in yet. `pathorsAI/coldmail` is the case that forced +this: everything it needs lives under `/outbox`, so until patchbay could be told +that, the repo could not use the env vault at all and fell back to +`infisical run --path /outbox -- ` by hand. + +So each project's sync config pins a **secret path** alongside the account: + +```sh +pb env link --project-id 3ab516bd-… --path /outbox +``` + +The default is `/`, the project's root, which is what every registry written +before the field existed meant and what every pull did. The spelling is +normalised on the way in — `outbox`, `/outbox/` and `/outbox` are one folder, +stored once as `/outbox` — so two links to the same place cannot produce two +entries that look different in `pb env projects` while pulling the same +secrets. An empty `--path ""` means the root, because somebody who passes it +means "no subfolder" rather than "a folder with no name". + +`pb env link` replaces the whole sync config, exactly as it does for `--domain` +and `--map`: re-linking without `--path` puts the project back on `/`. And the +path is only passed to the CLI when it is *not* `/` — `infisical export` +already defaults to the root, so sending `--path /` would change no result while +narrowing the CLI versions patchbay runs under. + +Where you see it: `pb env link` and `pb env init` echo a `secret path:` line, +`pb env projects` shows a non-root path in the SYNC column (`/` is left off, +since a column saying the same thing on every row is noise), and every pull +reports the folder it read. A pull that comes back empty says which folder was +empty and names the command that repoints it. + +**This is not a filesystem path**, and it is not an exception to *A project is a +name, not a path* above. That rule is about directories on **this machine**: +`projects.json` records no `/Users/you/repos/x`, because a manifest holding one +cannot travel to the next laptop — which is why the roots live in +`attachments.json` instead. A secret path is a coordinate **inside the remote**. +It is the same string for every teammate on every machine, exactly as portable +as the Infisical project id sitting next to it, and it belongs in the file you +copy. Two slash-separated strings, two entirely different lifetimes. + ### Getting values out Two commands read values, and they are the only two. @@ -290,7 +340,7 @@ 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,...] + [--path /outbox] [--domain ] [--map dev=development,...] pb env projects [--json] pb env list [-e ] [--project ] [--json] pb env pull [-e ] [--project ] [--json] @@ -444,4 +494,8 @@ 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`. +machine arrives through `pb env import`. The secret path is stored as the string +you gave it and is never checked against the remote: patchbay cannot tell a +folder that is empty from one that does not exist, because the CLI answers both +with an empty export and a zero exit code. What it can do is say which folder it +read, and it does, on every pull.