From a2fcbfd200d92518703bb63df4c0f612d8d0f8dc Mon Sep 17 00:00:00 2001 From: Matt Stavola Date: Fri, 17 Apr 2026 21:21:32 -0400 Subject: [PATCH] Add workspace support ([workspace] mlf.toml) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A workspace root is an mlf.toml with a [workspace] section and no [package] — members = ["app", "shared"] lists per-member subdirectories, each with its own [package]. Settings declared under [workspace.publish] and [workspace.dependencies] cascade into any member that doesn't override them (section-level replacement, not field-level merging). ProjectKind::load distinguishes the two shapes and returns either a single ResolvedPackage or the full member list. mlf publish dispatches based on that: single package → existing flow; workspace → publishes every member in declaration order. --package filters to one member by its [package].name. Each member runs through the full validator + DNS reconcile + apply + manifest pipeline independently — every member produces its own lol.mlf.package record. RemoteState::load_at lets the orchestrator target a specific package root without relying on CWD, so running from the workspace root publishes every member cleanly. --- mlf-cli/src/config.rs | 214 ++++++++++++++++++++++ mlf-cli/src/main.rs | 8 + mlf-cli/src/publish.rs | 53 +++++- mlf-cli/src/remote_state.rs | 14 +- website/content/docs/cli/13-workspaces.md | 91 +++++++++ 5 files changed, 373 insertions(+), 7 deletions(-) create mode 100644 website/content/docs/cli/13-workspaces.md diff --git a/mlf-cli/src/config.rs b/mlf-cli/src/config.rs index 7bf0575..e88a1ad 100644 --- a/mlf-cli/src/config.rs +++ b/mlf-cli/src/config.rs @@ -43,6 +43,112 @@ pub struct MlfConfig { pub publish: Option, } +/// Shape of a workspace-root `mlf.toml` (a `[workspace]` section with +/// member paths, no `[package]`). A project root can be either a +/// single package (see [`MlfConfig`]) or a workspace; the two are +/// parsed separately. +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct WorkspaceConfig { + /// Paths to member packages, relative to the workspace root. + /// Each must contain its own `mlf.toml` with `[package]`. + pub members: Vec, + + /// Inherited publish config — every member that doesn't declare + /// its own `[publish]` gets this one. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub publish: Option, + + /// Inherited dependencies config. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub dependencies: Option, +} + +/// One loaded package resolved from a workspace root (or a standalone +/// project). +#[derive(Debug, Clone)] +pub struct ResolvedPackage { + pub root: PathBuf, + pub config: MlfConfig, +} + +/// Result of loading the project root — either a single package, or a +/// workspace with many members. +#[derive(Debug, Clone)] +pub enum ProjectKind { + Package(ResolvedPackage), + Workspace { + root: PathBuf, + workspace: WorkspaceConfig, + members: Vec, + }, +} + +impl ProjectKind { + /// Load whichever of `[package]` or `[workspace]` is present at + /// `project_root/mlf.toml`. Errors if both or neither are present. + /// When a workspace, cascades its `[publish]` / `[dependencies]` + /// into any member that didn't declare its own. + pub fn load(project_root: &Path) -> Result { + let config_path = project_root.join("mlf.toml"); + let content = std::fs::read_to_string(&config_path)?; + // Try to parse as a single package first (strict); fall back to + // workspace if that fails with a missing-`[package]` error. + match toml::from_str::(&content) { + Ok(pkg_config) => Ok(ProjectKind::Package(ResolvedPackage { + root: project_root.to_path_buf(), + config: pkg_config, + })), + Err(pkg_err) => { + let workspace_parse: Result = toml::from_str(&content); + match workspace_parse { + Ok(root_file) => { + let ws = root_file.workspace; + let mut members = Vec::new(); + for rel in &ws.members { + let member_root = project_root.join(rel); + let mut member = MlfConfig::load(&member_root.join("mlf.toml")) + .map_err(|e| { + ConfigError::ReadError(std::io::Error::other(format!( + "failed to load workspace member {rel}: {e}" + ))) + })?; + // Cascade: fill in member's missing [publish] / + // [dependencies] from the workspace root. + if member.publish.is_none() && ws.publish.is_some() { + member.publish = ws.publish.clone(); + } + if member.dependencies.dependencies.is_empty() + && let Some(ref ws_deps) = ws.dependencies + { + member.dependencies = ws_deps.clone(); + } + members.push(ResolvedPackage { + root: member_root, + config: member, + }); + } + Ok(ProjectKind::Workspace { + root: project_root.to_path_buf(), + workspace: ws, + members, + }) + } + // Neither shape parsed: surface the package-shape + // error since that's the more common case. + Err(_) => Err(ConfigError::ParseError(pkg_err)), + } + } + } + } +} + +/// Only used to recognise a `[workspace]`-rooted `mlf.toml` during +/// [`ProjectKind::load`]. A workspace root has no `[package]`. +#[derive(Debug, Deserialize)] +struct WorkspaceRootFile { + workspace: WorkspaceConfig, +} + /// Identity of this MLF package. /// /// `name` is the NSID prefix every lexicon in the package must sit under — @@ -392,6 +498,114 @@ mod tests { assert_eq!(publish.breaking_changes, BreakingChangePolicy::Warn); } + #[test] + fn workspace_root_loads_members_and_cascades_publish() { + let dir = tempfile::TempDir::new().unwrap(); + let root = dir.path(); + // Workspace root mlf.toml: no [package], just [workspace]. + std::fs::write( + root.join("mlf.toml"), + r#" + [workspace] + members = ["app", "shared"] + + [workspace.publish] + dns = "cloudflare" + breaking_changes = "deny" + "#, + ) + .unwrap(); + // Two members. + for name in ["app", "shared"] { + std::fs::create_dir_all(root.join(name)).unwrap(); + std::fs::write( + root.join(name).join("mlf.toml"), + format!( + r#" + [package] + name = "com.example.{name}" + "# + ), + ) + .unwrap(); + } + + let project = ProjectKind::load(root).unwrap(); + match project { + ProjectKind::Workspace { members, .. } => { + assert_eq!(members.len(), 2); + // Workspace cascaded [publish] down to every member. + for member in &members { + let publish = member.config.publish.as_ref().unwrap(); + assert_eq!(publish.dns.as_deref(), Some("cloudflare")); + assert_eq!(publish.breaking_changes, BreakingChangePolicy::Deny); + } + } + ProjectKind::Package(_) => panic!("expected workspace"), + } + } + + #[test] + fn workspace_member_override_wins_over_root() { + let dir = tempfile::TempDir::new().unwrap(); + let root = dir.path(); + std::fs::write( + root.join("mlf.toml"), + r#" + [workspace] + members = ["app"] + + [workspace.publish] + dns = "cloudflare" + "#, + ) + .unwrap(); + std::fs::create_dir_all(root.join("app")).unwrap(); + std::fs::write( + root.join("app").join("mlf.toml"), + r#" + [package] + name = "com.example.app" + + [publish] + dns = "route53" + "#, + ) + .unwrap(); + + let project = ProjectKind::load(root).unwrap(); + match project { + ProjectKind::Workspace { members, .. } => { + let app = &members[0]; + assert_eq!( + app.config.publish.as_ref().unwrap().dns.as_deref(), + Some("route53"), + ); + } + ProjectKind::Package(_) => panic!("expected workspace"), + } + } + + #[test] + fn single_package_loads_as_package() { + let dir = tempfile::TempDir::new().unwrap(); + let root = dir.path(); + std::fs::write( + root.join("mlf.toml"), + r#" + [package] + name = "com.example.thing" + "#, + ) + .unwrap(); + match ProjectKind::load(root).unwrap() { + ProjectKind::Package(pkg) => { + assert_eq!(pkg.config.package.name, "com.example.thing"); + } + ProjectKind::Workspace { .. } => panic!("expected package"), + } + } + #[test] fn publish_defaults_apply() { let s = r#" diff --git a/mlf-cli/src/main.rs b/mlf-cli/src/main.rs index 7095636..b949042 100644 --- a/mlf-cli/src/main.rs +++ b/mlf-cli/src/main.rs @@ -115,6 +115,12 @@ enum Commands { )] non_interactive: bool, + #[arg( + long, + help = "In a workspace, publish only the named member (matched by [package].name)" + )] + package: Option, + /// Ephemeral credential overrides (not stored). Syntax mirrors /// login: `--pds-=value` or `--dns--=value`. #[arg( @@ -347,6 +353,7 @@ async fn main() { dry_run, force, non_interactive, + package, args, } => { let overrides = parse_publish_overrides(&args); @@ -354,6 +361,7 @@ async fn main() { dry_run, force, non_interactive, + package, overrides, }) .await diff --git a/mlf-cli/src/publish.rs b/mlf-cli/src/publish.rs index 706747c..0f064d7 100644 --- a/mlf-cli/src/publish.rs +++ b/mlf-cli/src/publish.rs @@ -14,7 +14,9 @@ //! 5. Authenticate to the PDS, apply the record actions, then publish //! the manifest record. -use crate::config::{BreakingChangePolicy, ConfigError, MlfConfig, find_project_root}; +use crate::config::{ + BreakingChangePolicy, ConfigError, ProjectKind, ResolvedPackage, find_project_root, +}; use crate::credentials::{CredentialsFile, Scope}; use crate::remote_state::{RemoteState, RemoteStateError}; use miette::Diagnostic; @@ -122,9 +124,42 @@ pub async fn run_publish(opts: PublishOpts) -> Result<(), PublishError> { ConfigError::NotFound => PublishError::Config("no mlf.toml found".into()), other => PublishError::Config(other.to_string()), })?; - let config_path = project_root.join("mlf.toml"); - let config = MlfConfig::load(&config_path).map_err(|e| PublishError::Config(e.to_string()))?; + let project = + ProjectKind::load(&project_root).map_err(|e| PublishError::Config(e.to_string()))?; + + // Dispatch single-package vs workspace. + match project { + ProjectKind::Package(pkg) => publish_package(&pkg, &opts).await, + ProjectKind::Workspace { members, .. } => { + let filtered: Vec<&ResolvedPackage> = match &opts.package { + Some(name) => { + let matched: Vec<&ResolvedPackage> = members + .iter() + .filter(|m| &m.config.package.name == name) + .collect(); + if matched.is_empty() { + return Err(PublishError::Config(format!( + "no workspace member with [package].name = `{name}`" + ))); + } + matched + } + None => members.iter().collect(), + }; + for (i, pkg) in filtered.iter().enumerate() { + if i > 0 { + println!("\n---"); + } + println!("## {}", pkg.config.package.name); + publish_package(pkg, &opts).await?; + } + Ok(()) + } + } +} +async fn publish_package(pkg: &ResolvedPackage, opts: &PublishOpts) -> Result<(), PublishError> { + let config = &pkg.config; let publish_cfg = config.publish.clone().ok_or(PublishError::NotPublishable)?; if !publish_cfg.enabled { return Err(PublishError::Disabled); @@ -132,7 +167,7 @@ pub async fn run_publish(opts: PublishOpts) -> Result<(), PublishError> { let dns_plugin = publish_cfg.dns.clone().ok_or(PublishError::NoDnsPlugin)?; println!("Loading workspace..."); - let state = RemoteState::load().await?; + let state = RemoteState::load_at(&pkg.root, config).await?; // 1. Scope + meta-schema validation. let scope_findings = @@ -225,8 +260,11 @@ pub async fn run_publish(opts: PublishOpts) -> Result<(), PublishError> { return Ok(()); } - // 4. Load credentials. - let creds = load_credentials(&project_root)?; + // 4. Load credentials. Credentials live at the workspace level (not + // per-member), so we use the member's parent dir when inside a + // workspace. Walking up until we find a credentials file would be + // cleaner but overkill for v1. + let creds = load_credentials(&pkg.root)?; let pds_creds = creds.pds.ok_or(PublishError::NoPdsCreds)?; let handle = pds_creds.handle.clone().ok_or(PublishError::NoPdsCreds)?; let app_password = pds_creds @@ -320,6 +358,9 @@ pub struct PublishOpts { pub dry_run: bool, pub force: bool, pub non_interactive: bool, + /// In a workspace, publish only the named member (by `[package].name`). + /// Ignored when the project root is a single package. + pub package: Option, /// Ephemeral credential overrides: `pds.` / `dns..`. /// These override the file-loaded values for this one invocation. pub overrides: BTreeMap, diff --git a/mlf-cli/src/remote_state.rs b/mlf-cli/src/remote_state.rs index e5b7ad7..9c29a06 100644 --- a/mlf-cli/src/remote_state.rs +++ b/mlf-cli/src/remote_state.rs @@ -152,7 +152,9 @@ pub enum AuthorityStatus { impl RemoteState { /// Build a [`RemoteState`] by walking the workspace, generating JSON, - /// and performing the necessary network lookups. + /// and performing the necessary network lookups. Uses CWD to find + /// the project root. For loading a specific package in a multi- + /// package workspace, use [`RemoteState::load_at`]. pub async fn load() -> Result { let current_dir = std::env::current_dir().map_err(|e| RemoteStateError::Io(e.to_string()))?; @@ -161,6 +163,16 @@ impl RemoteState { let config_path = project_root.join("mlf.toml"); let config = MlfConfig::load(&config_path).map_err(|e| RemoteStateError::Config(e.to_string()))?; + RemoteState::load_at(&project_root, &config).await + } + + /// Build a [`RemoteState`] for a specific package rooted at + /// `project_root`. The caller has already loaded `config`. + pub async fn load_at( + project_root: &Path, + config: &MlfConfig, + ) -> Result { + let project_root = project_root.to_path_buf(); let package = config.package.clone(); let source_dir = project_root.join(&config.source.directory); diff --git a/website/content/docs/cli/13-workspaces.md b/website/content/docs/cli/13-workspaces.md new file mode 100644 index 0000000..2f64b64 --- /dev/null +++ b/website/content/docs/cli/13-workspaces.md @@ -0,0 +1,91 @@ ++++ +title = "Workspaces" +description = "Multi-package repositories, cargo-style" +weight = 13 ++++ + +MLF supports multi-package workspaces — a single repository that hosts several publishable packages, each with its own `[package].name` and publish settings. The pattern matches `cargo`'s `[workspace]`: a root `mlf.toml` declares member paths, and each member has its own `mlf.toml` with `[package]`. + +## Root `mlf.toml` + +```toml +[workspace] +members = ["app", "shared"] + +[workspace.publish] +dns = "cloudflare" +breaking_changes = "deny" +manifest = true + +[workspace.dependencies] +dependencies = ["com.atproto"] +``` + +Only `[workspace]` is at the root — there is no `[package]`. `[workspace.publish]` and `[workspace.dependencies]` cascade into every member that doesn't declare its own. A member that redefines a section takes full precedence (no merging at the field level). + +## Member `mlf.toml` + +```toml +# app/mlf.toml +[package] +name = "com.example.app" + +# inherits [workspace.publish] + [workspace.dependencies] +``` + +Or override: + +```toml +# shared/mlf.toml +[package] +name = "com.example.shared" + +[publish] +dns = "route53" +``` + +Each member has its own `[package].name` and runs through every validator independently — scope, meta-schema, breaking-change, single-DID gate, DNS reconcile, record writes, manifest. + +## Commands + +- **`mlf publish`** from the workspace root → publishes every member in the order they're declared in `members`. +- **`mlf publish`** from inside a member directory → publishes just that member. +- **`mlf publish --package `** → publishes only the member whose `[package].name` matches. Can be run from anywhere; errors if no member matches. + +Other commands (`check`, `status`, `diff`, `fetch`, `generate`) are member-scoped — run them from inside a member directory. + +## Credentials + +Credentials continue to live at the global (`~/.config/mlf/credentials.toml`) or project-local (`/.mlf/credentials.toml`) level. Project-local in a workspace context means the workspace root's `.mlf/credentials.toml`, which every member shares. + +## Example: split-DNS workspace + +``` +repo/ +├── mlf.toml # [workspace] — cloudflare default +├── app/ +│ ├── mlf.toml # [package] name = com.example.app +│ └── lexicons/… +└── shared/ + ├── mlf.toml # [package] name = com.example.shared + [publish] dns = route53 + └── lexicons/… +``` + +```bash +mlf publish # publishes app (via cloudflare), then shared (via route53) +mlf publish --package com.example.shared # just shared +``` + +Each member produces its own `lol.mlf.package` manifest under its own NSID namespace. + +## What's not included in v1 + +- **Cross-member dependencies** — members can depend on external lexicons via `[dependencies]`, but there's no special handling for one member depending on another member's not-yet-published NSIDs. +- **Parallel publish** — members run sequentially so failures surface in order. + +Both are listed as future improvements; the current implementation is sufficient for the multi-package-per-zone use case the plan targeted. + +## See also + +- [`mlf publish`](../11-publish/) — the per-package flow that runs for each member. +- [Configuration → Package Identity](../02-configuration/#package-identity) — scope contract each member must satisfy. -- knotmirror 0.0.1