use std::path::{Path, PathBuf};
use dora_core::{
manifest::{MANIFEST_FILENAME, NodeManifest},
types::TypeRegistry,
};
use dora_hub_client::{
config::ResolvedConfig,
index::{IndexEntry, SourceSpec},
semver,
};
use eyre::{Context, ContextCompat, bail};
use crate::command::Executable;
#[derive(Debug, clap::Args)]
pub struct Publish {
#[clap(value_name = "PATH", default_value = ".")]
path: PathBuf,
#[clap(long, action)]
dry_run: bool,
#[clap(long, value_name = "REF")]
rev: Option<String>,
#[clap(long, value_name = "URL")]
repo: Option<String>,
#[clap(long, value_name = "SEMVER")]
version: Option<String>,
#[clap(long, value_name = "ALIAS")]
index: Option<String>,
}
impl Executable for Publish {
fn execute(self) -> eyre::Result<()> {
let dir = self.path.as_path();
let repo = match &self.repo {
Some(r) => r.trim().to_string(),
None => git_in(dir, &["remote", "get-url", "origin"]).context(
"no `--repo` given and the directory has no `origin` remote — \
pass `--repo <url>`",
)?,
};
dora_hub_client::validate_git_url_untrusted(&repo).with_context(|| {
format!("source repo `{repo}` is not a usable index source — pass a valid `--repo`")
})?;
let rev_arg = self.rev.as_deref().unwrap_or("HEAD");
let commit = git_in(dir, &["rev-parse", &format!("{rev_arg}^{{commit}}")])
.with_context(|| format!("failed to resolve git ref `{rev_arg}` to a commit"))?;
if !matches!(commit.len(), 40 | 64) || !commit.chars().all(|c| c.is_ascii_hexdigit()) {
bail!("resolved commit `{commit}` is not a full git hash (40 or 64 hex chars)");
}
let prefix = git_in(dir, &["rev-parse", "--show-prefix"])
.context("`dora hub publish` must be run inside the node's git repository")?;
let subdir = Some(prefix.trim_end_matches('/').to_string()).filter(|p| !p.is_empty());
let manifest_yaml = git_in(
dir,
&["show", &format!("{commit}:{prefix}{MANIFEST_FILENAME}")],
)
.with_context(|| {
format!(
"{MANIFEST_FILENAME} is not committed at {} — commit it (and any \
version bump) before publishing, or pass `--rev <commit>` that \
contains it",
&commit[..12]
)
})?;
let manifest = NodeManifest::parse(&manifest_yaml)
.with_context(|| format!("invalid {MANIFEST_FILENAME} at commit {}", &commit[..12]))?;
manifest
.validate_strict(&TypeRegistry::new())
.context("the node manifest is invalid — fix it before publishing")?;
let name = manifest
.name
.clone()
.context("manifest is missing `name`")?;
let namespace = manifest.namespace.clone();
let version_str = match &self.version {
Some(v) => v.clone(),
None => read_committed_version(dir, &commit, &prefix).context(
"could not determine the package version — pass `--version`, or commit \
`[package].version` (Cargo) / `[project].version` (pyproject)",
)?,
};
let version = semver::Version::parse(version_str.trim())
.with_context(|| format!("version `{version_str}` is not valid semver"))?;
let entry = IndexEntry {
manifest: manifest.clone(),
source: SourceSpec {
git: Some(repo.clone()),
rev: Some(commit.clone()),
subdir: subdir.clone(),
binary: Vec::new(),
fallback_git: None,
},
published: Some(chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)),
yanked: false,
yank_reason: None,
};
let entry_yaml =
serde_yaml::to_string(&entry).context("failed to serialize index entry")?;
let rel_path = format!("{namespace}/{name}/{version}.yml");
let config = ResolvedConfig::load_default().context("failed to load hub configuration")?;
let index = match &self.index {
Some(alias) => {
let target = config
.indexes
.iter()
.find(|i| i.alias.eq_ignore_ascii_case(alias))
.with_context(|| format!("no index with alias `{alias}` in hub.toml"))?;
let bound = config.index_for_namespace(&namespace);
if !bound.alias.eq_ignore_ascii_case(&target.alias) {
bail!(
"namespace `{namespace}` is bound to index `{}`, not `{}` — publish \
into `{}`, or bind the namespace to `{}` in hub.toml",
bound.alias,
target.alias,
bound.alias,
target.alias
);
}
target
}
None => config.index_for_namespace(&namespace),
};
if self.dry_run {
println!("# {rel_path} (index: {})", index.alias);
print!("{entry_yaml}");
println!(
"\nvalidated `{namespace}/{name}` v{version} @ {} (subdir: {})",
&commit[..12],
subdir.as_deref().unwrap_or(".")
);
return Ok(());
}
if index.is_local() {
let catalog = index.local_catalog_dir(&config.config_dir)?;
let dest = catalog.join(&rel_path);
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("failed to create `{}`", parent.display()))?;
}
let mut file = match std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&dest)
{
Ok(file) => file,
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => bail!(
"version {version} of `{namespace}/{name}` already exists at `{}` — \
the index is append-only; bump the version to publish a new one",
dest.display()
),
Err(e) => {
return Err(e).with_context(|| format!("failed to write `{}`", dest.display()));
}
};
std::io::Write::write_all(&mut file, entry_yaml.as_bytes())
.with_context(|| format!("failed to write `{}`", dest.display()))?;
println!(
"published `{namespace}/{name}` v{version} -> {}",
dest.display()
);
} else {
println!("# add this as `{rel_path}` in the index repo:");
println!("{entry_yaml}");
println!(
"the `{}` index is git-backed ({}). Commit the entry above at `{rel_path}` and \
open a PR. Automated `dora hub publish` PR-opening lands with the node-index \
bootstrap (P2.3).",
index.alias,
index.git.as_deref().unwrap_or("?")
);
}
Ok(())
}
}
fn git_in(dir: &Path, args: &[&str]) -> eyre::Result<String> {
let output = std::process::Command::new("git")
.arg("-C")
.arg(dir)
.args(args)
.output()
.context("failed to run git")?;
if !output.status.success() {
bail!(
"git {args:?} failed: {}",
String::from_utf8_lossy(&output.stderr).trim()
);
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
fn read_committed_version(dir: &Path, commit: &str, prefix: &str) -> Option<String> {
let read = |file: &str| git_in(dir, &["show", &format!("{commit}:{prefix}{file}")]).ok();
if let Some(raw) = read("Cargo.toml")
&& let Ok(t) = raw.parse::<toml::Table>()
&& let Some(v) = t
.get("package")
.and_then(|p| p.get("version"))
.and_then(|v| v.as_str())
{
return Some(v.to_string());
}
if let Some(raw) = read("pyproject.toml")
&& let Ok(t) = raw.parse::<toml::Table>()
&& let Some(v) = t
.get("project")
.and_then(|p| p.get("version"))
.and_then(|v| v.as_str())
{
return Some(v.to_string());
}
None
}