Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 -- <cmd>` by hand.

`pb env link --project-id <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
Expand Down
197 changes: 156 additions & 41 deletions crates/patchbay-cli/src/env.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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<String>,
/// 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<String>,
/// API base URL, for self-hosted or EU instances.
#[arg(long, value_name = "URL")]
domain: Option<String>,
Expand Down Expand Up @@ -316,6 +326,7 @@ pub fn run(command: Command, styles: &Styles) -> Result<i32> {
project_id,
project,
account,
path,
domain,
map,
} => {
Expand All @@ -337,6 +348,11 @@ pub fn run(command: Command, styles: &Styles) -> Result<i32> {
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()),
},
)?;

Expand Down Expand Up @@ -410,6 +426,7 @@ pub fn run(command: Command, styles: &Styles) -> Result<i32> {
outcome.env
);
println!(" remote environment: {}", outcome.remote_env);
println!(" secret path: {}", outcome.secret_path);
for note in &outcome.notes {
println!(" note: {note}");
}
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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}");
}
Expand Down Expand Up @@ -1079,54 +1104,93 @@ fn column_width(values: impl Iterator<Item = usize>, 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<String> = 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<String, Vec<PathBuf>>,
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<String> = 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()),
Expand All @@ -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,
);
Expand All @@ -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)
Expand Down Expand Up @@ -1403,6 +1470,7 @@ mod tests {
account: "contact@pathors.com".into(),
domain: None,
env_map: BTreeMap::new(),
secret_path: DEFAULT_SECRET_PATH.into(),
},
)
.unwrap();
Expand All @@ -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(
&registry.projects().unwrap(),
&roots_of(&registry),
&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();
Expand Down
Loading
Loading