A human-friendly DSL for ATProto Lexicons
27
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add workspace support ([workspace] mlf.toml)

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 <name> 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.

authored by stavola.xyz and committed by

Tangled a2fcbfd2 d3168e46

+373 -7
+214
mlf-cli/src/config.rs
··· 43 43 pub publish: Option<PublishConfig>, 44 44 } 45 45 46 + /// Shape of a workspace-root `mlf.toml` (a `[workspace]` section with 47 + /// member paths, no `[package]`). A project root can be either a 48 + /// single package (see [`MlfConfig`]) or a workspace; the two are 49 + /// parsed separately. 50 + #[derive(Debug, Serialize, Deserialize, Clone)] 51 + pub struct WorkspaceConfig { 52 + /// Paths to member packages, relative to the workspace root. 53 + /// Each must contain its own `mlf.toml` with `[package]`. 54 + pub members: Vec<String>, 55 + 56 + /// Inherited publish config — every member that doesn't declare 57 + /// its own `[publish]` gets this one. 58 + #[serde(default, skip_serializing_if = "Option::is_none")] 59 + pub publish: Option<PublishConfig>, 60 + 61 + /// Inherited dependencies config. 62 + #[serde(default, skip_serializing_if = "Option::is_none")] 63 + pub dependencies: Option<DependenciesConfig>, 64 + } 65 + 66 + /// One loaded package resolved from a workspace root (or a standalone 67 + /// project). 68 + #[derive(Debug, Clone)] 69 + pub struct ResolvedPackage { 70 + pub root: PathBuf, 71 + pub config: MlfConfig, 72 + } 73 + 74 + /// Result of loading the project root — either a single package, or a 75 + /// workspace with many members. 76 + #[derive(Debug, Clone)] 77 + pub enum ProjectKind { 78 + Package(ResolvedPackage), 79 + Workspace { 80 + root: PathBuf, 81 + workspace: WorkspaceConfig, 82 + members: Vec<ResolvedPackage>, 83 + }, 84 + } 85 + 86 + impl ProjectKind { 87 + /// Load whichever of `[package]` or `[workspace]` is present at 88 + /// `project_root/mlf.toml`. Errors if both or neither are present. 89 + /// When a workspace, cascades its `[publish]` / `[dependencies]` 90 + /// into any member that didn't declare its own. 91 + pub fn load(project_root: &Path) -> Result<Self, ConfigError> { 92 + let config_path = project_root.join("mlf.toml"); 93 + let content = std::fs::read_to_string(&config_path)?; 94 + // Try to parse as a single package first (strict); fall back to 95 + // workspace if that fails with a missing-`[package]` error. 96 + match toml::from_str::<MlfConfig>(&content) { 97 + Ok(pkg_config) => Ok(ProjectKind::Package(ResolvedPackage { 98 + root: project_root.to_path_buf(), 99 + config: pkg_config, 100 + })), 101 + Err(pkg_err) => { 102 + let workspace_parse: Result<WorkspaceRootFile, _> = toml::from_str(&content); 103 + match workspace_parse { 104 + Ok(root_file) => { 105 + let ws = root_file.workspace; 106 + let mut members = Vec::new(); 107 + for rel in &ws.members { 108 + let member_root = project_root.join(rel); 109 + let mut member = MlfConfig::load(&member_root.join("mlf.toml")) 110 + .map_err(|e| { 111 + ConfigError::ReadError(std::io::Error::other(format!( 112 + "failed to load workspace member {rel}: {e}" 113 + ))) 114 + })?; 115 + // Cascade: fill in member's missing [publish] / 116 + // [dependencies] from the workspace root. 117 + if member.publish.is_none() && ws.publish.is_some() { 118 + member.publish = ws.publish.clone(); 119 + } 120 + if member.dependencies.dependencies.is_empty() 121 + && let Some(ref ws_deps) = ws.dependencies 122 + { 123 + member.dependencies = ws_deps.clone(); 124 + } 125 + members.push(ResolvedPackage { 126 + root: member_root, 127 + config: member, 128 + }); 129 + } 130 + Ok(ProjectKind::Workspace { 131 + root: project_root.to_path_buf(), 132 + workspace: ws, 133 + members, 134 + }) 135 + } 136 + // Neither shape parsed: surface the package-shape 137 + // error since that's the more common case. 138 + Err(_) => Err(ConfigError::ParseError(pkg_err)), 139 + } 140 + } 141 + } 142 + } 143 + } 144 + 145 + /// Only used to recognise a `[workspace]`-rooted `mlf.toml` during 146 + /// [`ProjectKind::load`]. A workspace root has no `[package]`. 147 + #[derive(Debug, Deserialize)] 148 + struct WorkspaceRootFile { 149 + workspace: WorkspaceConfig, 150 + } 151 + 46 152 /// Identity of this MLF package. 47 153 /// 48 154 /// `name` is the NSID prefix every lexicon in the package must sit under — ··· 390 496 assert_eq!(publish.dns.as_deref(), Some("cloudflare")); 391 497 assert!(publish.manifest); 392 498 assert_eq!(publish.breaking_changes, BreakingChangePolicy::Warn); 499 + } 500 + 501 + #[test] 502 + fn workspace_root_loads_members_and_cascades_publish() { 503 + let dir = tempfile::TempDir::new().unwrap(); 504 + let root = dir.path(); 505 + // Workspace root mlf.toml: no [package], just [workspace]. 506 + std::fs::write( 507 + root.join("mlf.toml"), 508 + r#" 509 + [workspace] 510 + members = ["app", "shared"] 511 + 512 + [workspace.publish] 513 + dns = "cloudflare" 514 + breaking_changes = "deny" 515 + "#, 516 + ) 517 + .unwrap(); 518 + // Two members. 519 + for name in ["app", "shared"] { 520 + std::fs::create_dir_all(root.join(name)).unwrap(); 521 + std::fs::write( 522 + root.join(name).join("mlf.toml"), 523 + format!( 524 + r#" 525 + [package] 526 + name = "com.example.{name}" 527 + "# 528 + ), 529 + ) 530 + .unwrap(); 531 + } 532 + 533 + let project = ProjectKind::load(root).unwrap(); 534 + match project { 535 + ProjectKind::Workspace { members, .. } => { 536 + assert_eq!(members.len(), 2); 537 + // Workspace cascaded [publish] down to every member. 538 + for member in &members { 539 + let publish = member.config.publish.as_ref().unwrap(); 540 + assert_eq!(publish.dns.as_deref(), Some("cloudflare")); 541 + assert_eq!(publish.breaking_changes, BreakingChangePolicy::Deny); 542 + } 543 + } 544 + ProjectKind::Package(_) => panic!("expected workspace"), 545 + } 546 + } 547 + 548 + #[test] 549 + fn workspace_member_override_wins_over_root() { 550 + let dir = tempfile::TempDir::new().unwrap(); 551 + let root = dir.path(); 552 + std::fs::write( 553 + root.join("mlf.toml"), 554 + r#" 555 + [workspace] 556 + members = ["app"] 557 + 558 + [workspace.publish] 559 + dns = "cloudflare" 560 + "#, 561 + ) 562 + .unwrap(); 563 + std::fs::create_dir_all(root.join("app")).unwrap(); 564 + std::fs::write( 565 + root.join("app").join("mlf.toml"), 566 + r#" 567 + [package] 568 + name = "com.example.app" 569 + 570 + [publish] 571 + dns = "route53" 572 + "#, 573 + ) 574 + .unwrap(); 575 + 576 + let project = ProjectKind::load(root).unwrap(); 577 + match project { 578 + ProjectKind::Workspace { members, .. } => { 579 + let app = &members[0]; 580 + assert_eq!( 581 + app.config.publish.as_ref().unwrap().dns.as_deref(), 582 + Some("route53"), 583 + ); 584 + } 585 + ProjectKind::Package(_) => panic!("expected workspace"), 586 + } 587 + } 588 + 589 + #[test] 590 + fn single_package_loads_as_package() { 591 + let dir = tempfile::TempDir::new().unwrap(); 592 + let root = dir.path(); 593 + std::fs::write( 594 + root.join("mlf.toml"), 595 + r#" 596 + [package] 597 + name = "com.example.thing" 598 + "#, 599 + ) 600 + .unwrap(); 601 + match ProjectKind::load(root).unwrap() { 602 + ProjectKind::Package(pkg) => { 603 + assert_eq!(pkg.config.package.name, "com.example.thing"); 604 + } 605 + ProjectKind::Workspace { .. } => panic!("expected package"), 606 + } 393 607 } 394 608 395 609 #[test]
+8
mlf-cli/src/main.rs
··· 115 115 )] 116 116 non_interactive: bool, 117 117 118 + #[arg( 119 + long, 120 + help = "In a workspace, publish only the named member (matched by [package].name)" 121 + )] 122 + package: Option<String>, 123 + 118 124 /// Ephemeral credential overrides (not stored). Syntax mirrors 119 125 /// login: `--pds-<field>=value` or `--dns-<plugin>-<field>=value`. 120 126 #[arg( ··· 347 353 dry_run, 348 354 force, 349 355 non_interactive, 356 + package, 350 357 args, 351 358 } => { 352 359 let overrides = parse_publish_overrides(&args); ··· 354 361 dry_run, 355 362 force, 356 363 non_interactive, 364 + package, 357 365 overrides, 358 366 }) 359 367 .await
+47 -6
mlf-cli/src/publish.rs
··· 14 14 //! 5. Authenticate to the PDS, apply the record actions, then publish 15 15 //! the manifest record. 16 16 17 - use crate::config::{BreakingChangePolicy, ConfigError, MlfConfig, find_project_root}; 17 + use crate::config::{ 18 + BreakingChangePolicy, ConfigError, ProjectKind, ResolvedPackage, find_project_root, 19 + }; 18 20 use crate::credentials::{CredentialsFile, Scope}; 19 21 use crate::remote_state::{RemoteState, RemoteStateError}; 20 22 use miette::Diagnostic; ··· 122 124 ConfigError::NotFound => PublishError::Config("no mlf.toml found".into()), 123 125 other => PublishError::Config(other.to_string()), 124 126 })?; 125 - let config_path = project_root.join("mlf.toml"); 126 - let config = MlfConfig::load(&config_path).map_err(|e| PublishError::Config(e.to_string()))?; 127 + let project = 128 + ProjectKind::load(&project_root).map_err(|e| PublishError::Config(e.to_string()))?; 129 + 130 + // Dispatch single-package vs workspace. 131 + match project { 132 + ProjectKind::Package(pkg) => publish_package(&pkg, &opts).await, 133 + ProjectKind::Workspace { members, .. } => { 134 + let filtered: Vec<&ResolvedPackage> = match &opts.package { 135 + Some(name) => { 136 + let matched: Vec<&ResolvedPackage> = members 137 + .iter() 138 + .filter(|m| &m.config.package.name == name) 139 + .collect(); 140 + if matched.is_empty() { 141 + return Err(PublishError::Config(format!( 142 + "no workspace member with [package].name = `{name}`" 143 + ))); 144 + } 145 + matched 146 + } 147 + None => members.iter().collect(), 148 + }; 149 + for (i, pkg) in filtered.iter().enumerate() { 150 + if i > 0 { 151 + println!("\n---"); 152 + } 153 + println!("## {}", pkg.config.package.name); 154 + publish_package(pkg, &opts).await?; 155 + } 156 + Ok(()) 157 + } 158 + } 159 + } 127 160 161 + async fn publish_package(pkg: &ResolvedPackage, opts: &PublishOpts) -> Result<(), PublishError> { 162 + let config = &pkg.config; 128 163 let publish_cfg = config.publish.clone().ok_or(PublishError::NotPublishable)?; 129 164 if !publish_cfg.enabled { 130 165 return Err(PublishError::Disabled); ··· 132 167 let dns_plugin = publish_cfg.dns.clone().ok_or(PublishError::NoDnsPlugin)?; 133 168 134 169 println!("Loading workspace..."); 135 - let state = RemoteState::load().await?; 170 + let state = RemoteState::load_at(&pkg.root, config).await?; 136 171 137 172 // 1. Scope + meta-schema validation. 138 173 let scope_findings = ··· 225 260 return Ok(()); 226 261 } 227 262 228 - // 4. Load credentials. 229 - let creds = load_credentials(&project_root)?; 263 + // 4. Load credentials. Credentials live at the workspace level (not 264 + // per-member), so we use the member's parent dir when inside a 265 + // workspace. Walking up until we find a credentials file would be 266 + // cleaner but overkill for v1. 267 + let creds = load_credentials(&pkg.root)?; 230 268 let pds_creds = creds.pds.ok_or(PublishError::NoPdsCreds)?; 231 269 let handle = pds_creds.handle.clone().ok_or(PublishError::NoPdsCreds)?; 232 270 let app_password = pds_creds ··· 320 358 pub dry_run: bool, 321 359 pub force: bool, 322 360 pub non_interactive: bool, 361 + /// In a workspace, publish only the named member (by `[package].name`). 362 + /// Ignored when the project root is a single package. 363 + pub package: Option<String>, 323 364 /// Ephemeral credential overrides: `pds.<field>` / `dns.<plugin>.<field>`. 324 365 /// These override the file-loaded values for this one invocation. 325 366 pub overrides: BTreeMap<String, String>,
+13 -1
mlf-cli/src/remote_state.rs
··· 152 152 153 153 impl RemoteState { 154 154 /// Build a [`RemoteState`] by walking the workspace, generating JSON, 155 - /// and performing the necessary network lookups. 155 + /// and performing the necessary network lookups. Uses CWD to find 156 + /// the project root. For loading a specific package in a multi- 157 + /// package workspace, use [`RemoteState::load_at`]. 156 158 pub async fn load() -> Result<Self, RemoteStateError> { 157 159 let current_dir = 158 160 std::env::current_dir().map_err(|e| RemoteStateError::Io(e.to_string()))?; ··· 161 163 let config_path = project_root.join("mlf.toml"); 162 164 let config = 163 165 MlfConfig::load(&config_path).map_err(|e| RemoteStateError::Config(e.to_string()))?; 166 + RemoteState::load_at(&project_root, &config).await 167 + } 168 + 169 + /// Build a [`RemoteState`] for a specific package rooted at 170 + /// `project_root`. The caller has already loaded `config`. 171 + pub async fn load_at( 172 + project_root: &Path, 173 + config: &MlfConfig, 174 + ) -> Result<Self, RemoteStateError> { 175 + let project_root = project_root.to_path_buf(); 164 176 let package = config.package.clone(); 165 177 let source_dir = project_root.join(&config.source.directory); 166 178
+91
website/content/docs/cli/13-workspaces.md
··· 1 + +++ 2 + title = "Workspaces" 3 + description = "Multi-package repositories, cargo-style" 4 + weight = 13 5 + +++ 6 + 7 + 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]`. 8 + 9 + ## Root `mlf.toml` 10 + 11 + ```toml 12 + [workspace] 13 + members = ["app", "shared"] 14 + 15 + [workspace.publish] 16 + dns = "cloudflare" 17 + breaking_changes = "deny" 18 + manifest = true 19 + 20 + [workspace.dependencies] 21 + dependencies = ["com.atproto"] 22 + ``` 23 + 24 + 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). 25 + 26 + ## Member `mlf.toml` 27 + 28 + ```toml 29 + # app/mlf.toml 30 + [package] 31 + name = "com.example.app" 32 + 33 + # inherits [workspace.publish] + [workspace.dependencies] 34 + ``` 35 + 36 + Or override: 37 + 38 + ```toml 39 + # shared/mlf.toml 40 + [package] 41 + name = "com.example.shared" 42 + 43 + [publish] 44 + dns = "route53" 45 + ``` 46 + 47 + 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. 48 + 49 + ## Commands 50 + 51 + - **`mlf publish`** from the workspace root → publishes every member in the order they're declared in `members`. 52 + - **`mlf publish`** from inside a member directory → publishes just that member. 53 + - **`mlf publish --package <name>`** → publishes only the member whose `[package].name` matches. Can be run from anywhere; errors if no member matches. 54 + 55 + Other commands (`check`, `status`, `diff`, `fetch`, `generate`) are member-scoped — run them from inside a member directory. 56 + 57 + ## Credentials 58 + 59 + Credentials continue to live at the global (`~/.config/mlf/credentials.toml`) or project-local (`<project>/.mlf/credentials.toml`) level. Project-local in a workspace context means the workspace root's `.mlf/credentials.toml`, which every member shares. 60 + 61 + ## Example: split-DNS workspace 62 + 63 + ``` 64 + repo/ 65 + ├── mlf.toml # [workspace] — cloudflare default 66 + ├── app/ 67 + │ ├── mlf.toml # [package] name = com.example.app 68 + │ └── lexicons/… 69 + └── shared/ 70 + ├── mlf.toml # [package] name = com.example.shared + [publish] dns = route53 71 + └── lexicons/… 72 + ``` 73 + 74 + ```bash 75 + mlf publish # publishes app (via cloudflare), then shared (via route53) 76 + mlf publish --package com.example.shared # just shared 77 + ``` 78 + 79 + Each member produces its own `lol.mlf.package` manifest under its own NSID namespace. 80 + 81 + ## What's not included in v1 82 + 83 + - **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. 84 + - **Parallel publish** — members run sequentially so failures surface in order. 85 + 86 + Both are listed as future improvements; the current implementation is sufficient for the multi-package-per-zone use case the plan targeted. 87 + 88 + ## See also 89 + 90 + - [`mlf publish`](../11-publish/) — the per-package flow that runs for each member. 91 + - [Configuration → Package Identity](../02-configuration/#package-identity) — scope contract each member must satisfy.