use crate::error::Error;
use crate::net::{auth, registry};
use crate::project::manifest::{
DEFAULT_INDEX_NAME, DEFAULT_INDEX_URL, Dependency, Environment, Manifest, is_github_username,
split_package_name, workspace_version_req,
};
use crate::project::workspace::{self, Workspace};
use crate::registry::index::Index;
use crate::registry::pack;
use crate::ui;
use clap::Args;
use std::path::Path;
#[derive(Args, Debug)]
pub struct PublishArgs {
#[arg(long)]
pub dry_run: bool,
}
pub fn run(args: PublishArgs) -> Result<(), Error> {
let manifest = Manifest::load()?;
if manifest.workspace_members().is_empty() {
return publish_project(manifest, args.dry_run);
}
let workspace = Workspace::open(Path::new("."))?;
let mut failed = Vec::new();
let root_name = manifest.package.name.clone();
if let Err(error) = publish_project(manifest, args.dry_run) {
ui::print_error(&format!("{root_name}: {error}"));
failed.push(root_name);
}
for member in &workspace.members {
if member.dir == workspace.root {
continue;
}
let name = member.manifest.package.name.clone();
let result = workspace::in_dir(&member.dir, || {
publish_project(Manifest::load()?, args.dry_run)
});
if let Err(error) = result {
ui::print_error(&format!("{name}: {error}"));
failed.push(name);
}
}
if failed.is_empty() {
Ok(())
} else {
Err(Error::WorkspacePublishFailed(failed))
}
}
fn publish_project(mut manifest: Manifest, dry_run: bool) -> Result<(), Error> {
if manifest.package.private {
println!(
"Skipping {}: package is private, refusing to publish",
manifest.package.name
);
return Ok(());
}
let has_main = manifest
.target
.as_ref()
.is_some_and(|target| target.main.is_some());
if !manifest.workspace_members().is_empty() && !has_main {
println!(
"Skipping {}: workspace root without a main",
manifest.package.name
);
return Ok(());
}
let environment = publish_environment(&manifest)?;
let (scope, name) = {
let (scope, name) = split_package_name(&manifest.package.name)?;
(scope.to_string(), name.to_string())
};
let version = semver::Version::parse(&manifest.package.version)?;
if let Some(author) = manifest
.package
.authors
.iter()
.find(|author| !is_github_username(author))
{
return Err(Error::ManifestInvalid(format!(
"author '{author}' is not a GitHub username; [package] authors grant publish access to your scope and must be GitHub usernames (no emails or display names)"
)));
}
validate_description(manifest.package.description.as_deref())?;
convert_workspace_dependencies(&mut manifest, Path::new("."))?;
let root = Path::new(".");
let files = pack::packed_files(root, &manifest)?;
let archive = ui::with_spinner("Packing package", || pack::pack(root, &manifest))?;
if archive.len() > registry::MAX_ARCHIVE_BYTES {
return Err(Error::PublishTooLarge {
size_mb: archive.len() as f64 / (1024.0 * 1024.0),
limit_mb: (registry::MAX_ARCHIVE_BYTES / (1024 * 1024)) as u64,
});
}
if dry_run {
println!(
"Would publish {}@{version} ({environment}) to {}:",
manifest.package.name,
registry::API_URL,
);
println!(
" {scope}/{name}/{version}.tar.gz ({} bytes)",
archive.len()
);
for file in &files {
println!(" {}", file.display());
}
return Ok(());
}
let credentials = match auth::load()? {
Some(credentials) => credentials,
None => auth::login(&oauth_client_id()?)?,
};
match upload(&credentials.token, &archive) {
Err(Error::PublishFailed { status: 401, .. }) => {
auth::clear()?;
eprintln!("warning: the registry rejected the stored GitHub token; logging in again");
let credentials = auth::login(&oauth_client_id()?)?;
upload(&credentials.token, &archive)?;
}
other => other?,
}
ui::print_success(&format!(
"Published {}@{version} ({environment})",
manifest.package.name
));
println!("Install it with `lpm add {}`", manifest.package.name);
Ok(())
}
fn convert_workspace_dependencies(
manifest: &mut Manifest,
project_dir: &Path,
) -> Result<(), Error> {
if !manifest
.dependencies
.values()
.any(|dependency| matches!(dependency, Dependency::Workspace { .. }))
{
return Ok(());
}
let workspace = if manifest.workspace_members().is_empty() {
workspace::containing(project_dir)?
} else {
Some(Workspace::open(project_dir)?)
};
for (alias, dependency) in manifest.dependencies.iter_mut() {
let Dependency::Workspace {
workspace: name,
version,
} = dependency
else {
continue;
};
let workspace = workspace
.as_ref()
.ok_or_else(|| Error::NotInWorkspace(alias.clone()))?;
let member = workspace
.member(name)
.ok_or_else(|| Error::NoWorkspaceMember(name.clone()))?;
let member_version = semver::Version::parse(&member.manifest.package.version)?;
*dependency = Dependency::Registry {
name: name.clone(),
version: workspace_version_req(version, &member_version)?,
index: member
.manifest
.indices
.get(DEFAULT_INDEX_NAME)
.filter(|url| url.as_str() != DEFAULT_INDEX_URL)
.cloned(),
};
}
Ok(())
}
fn validate_description(description: Option<&str>) -> Result<(), Error> {
let Some(description) = description else {
return Ok(());
};
let length = description.chars().count();
if length > registry::MAX_DESCRIPTION_CHARS {
return Err(Error::ManifestInvalid(format!(
"description is {length} characters; the registry allows at most {}",
registry::MAX_DESCRIPTION_CHARS
)));
}
Ok(())
}
fn upload(token: &str, archive: &[u8]) -> Result<(), Error> {
ui::with_spinner("Uploading package", || registry::publish(token, archive))
}
fn oauth_client_id() -> Result<String, Error> {
let index = Index::open(DEFAULT_INDEX_URL, true)?;
index
.github_oauth_id()
.map(str::to_string)
.ok_or_else(|| Error::PublishNotSupported(DEFAULT_INDEX_URL.to_string()))
}
fn publish_environment(manifest: &Manifest) -> Result<Environment, Error> {
manifest
.target
.as_ref()
.map(|target| target.environment)
.ok_or_else(|| {
Error::ManifestInvalid(
"publishing requires a [target] section with an environment".to_string(),
)
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
#[test]
fn workspace_dependencies_convert_to_registry_specs() {
let base = std::env::temp_dir().join("lpm-test-publish-convert");
let _ = fs::remove_dir_all(&base);
let write = |path: &str, contents: &str| {
let path = base.join(path);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, contents).unwrap();
};
write(
"lpm.toml",
"[package]\nname = \"acme/root\"\nversion = \"0.0.0\"\nprivate = true\n\n\
[target]\nenvironment = \"shared\"\nworkspace = [\"packages/*\"]\n",
);
write(
"packages/core/lpm.toml",
"[package]\nname = \"acme/core\"\nversion = \"1.2.3\"\n\n[target]\nenvironment = \"shared\"\n",
);
write(
"packages/extra/lpm.toml",
"[package]\nname = \"acme/extra\"\nversion = \"0.2.0\"\n\n[target]\nenvironment = \"shared\"\n\n\
[dependencies]\ncore = { workspace = \"acme/core\", version = \"~\" }\n",
);
let member_dir = base.join("packages/extra");
let mut manifest = Manifest::load_from(&member_dir.join("lpm.toml")).unwrap();
convert_workspace_dependencies(&mut manifest, &member_dir).unwrap();
assert!(matches!(
&manifest.dependencies["core"],
Dependency::Registry { name, version, index: None }
if name == "acme/core" && version == "~1.2.3"
));
let serialized = toml::to_string(&manifest).unwrap();
assert!(serialized.contains(r#"name = "acme/core""#));
assert!(serialized.contains(r#"version = "~1.2.3""#));
assert!(!serialized.contains("workspace"));
let lonely = base.join("lonely");
fs::create_dir_all(&lonely).unwrap();
let mut orphaned = manifest;
orphaned.dependencies.insert(
"again".to_string(),
Dependency::Workspace {
workspace: "acme/core".to_string(),
version: "^".to_string(),
},
);
assert!(matches!(
convert_workspace_dependencies(&mut orphaned, &lonely),
Err(Error::NotInWorkspace(_))
));
let _ = fs::remove_dir_all(&base);
}
#[test]
fn descriptions_past_the_cap_are_rejected() {
assert!(validate_description(None).is_ok());
assert!(validate_description(Some("short and sweet")).is_ok());
assert!(validate_description(Some(&"a".repeat(200))).is_ok());
assert!(matches!(
validate_description(Some(&"a".repeat(201))),
Err(Error::ManifestInvalid(_))
));
assert!(validate_description(Some(&"é".repeat(200))).is_ok());
}
}