use std::path::{Path, PathBuf};
use logoxide::{
LogoxideError, MemoryStorage, Storage,
catalog::CatalogRelease,
git::{Fetcher, GitRef},
publish,
storage::write_atomic,
};
pub enum Host {
Folder(PathBuf),
GitHub(String),
}
impl Host {
pub fn parse(text: &str) -> Host {
match text.strip_prefix("github:") {
Some(repo) => Host::GitHub(repo.trim_matches('/').to_string()),
None => Host::Folder(PathBuf::from(text)),
}
}
fn catalog_location(&self) -> String {
match self {
Host::Folder(dir) => dir.join(CATALOG).display().to_string(),
Host::GitHub(repo) => {
format!("https://github.com/{repo}/releases/download/{INDEX}/{CATALOG}")
}
}
}
fn default_name(&self) -> String {
let name = match self {
Host::Folder(dir) => (dir.canonicalize().ok())
.and_then(|d| d.file_name().map(|n| n.to_string_lossy().into_owned())),
Host::GitHub(repo) => repo.rsplit('/').next().map(String::from),
};
name.unwrap_or_else(|| String::from("registry"))
}
}
const CATALOG: &str = "catalog.toml";
const INDEX: &str = "index";
fn io(path: &Path, e: std::io::Error) -> LogoxideError {
LogoxideError::io(&path.display().to_string(), e)
}
fn write(path: &Path, bytes: impl AsRef<[u8]>) -> logoxide::Result<()> {
write_atomic(path, bytes.as_ref()).map_err(|e| io(path, e))
}
fn gh(args: &[&str]) -> logoxide::Result<String> {
let output = std::process::Command::new("gh")
.args(args)
.output()
.map_err(|e| {
LogoxideError::network(
"gh",
format!("couldn't run gh (https://cli.github.com, then gh auth login): {e}"),
)
})?;
if !output.status.success() {
return Err(LogoxideError::network(
&format!("gh {}", args.join(" ")),
String::from_utf8_lossy(&output.stderr).trim().to_string(),
));
}
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
}
fn scratch(what: &str) -> logoxide::Result<PathBuf> {
let dir = std::env::temp_dir().join(format!("lx-{what}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).map_err(|e| io(&dir, e))?;
Ok(dir)
}
const README: &str = "# A logoxide registry
`catalog.toml` lists each package and its releases; each release is an archive beside it
(`<id>/<id>-<version>.tar.gz`), with its SHA-256. `lx publish --to <this folder>` adds one.
Serve this folder from any static host (a web server, GitHub Pages, Netlify, an S3 bucket), then:
```sh
lx catalog add https://example.org/<where>/catalog.toml --name <name>
lx add <name>:<id>
```
On this computer it's a registry already: `lx catalog add <this folder>/catalog.toml --name <name>`.
";
pub fn init(host: &Host, name: Option<&str>, private: bool) -> logoxide::Result<()> {
let name = name.map_or_else(|| host.default_name(), String::from);
let catalog =
format!("# A logoxide registry: lx publish --to … adds releases\nname = \"{name}\"\n");
match host {
Host::Folder(dir) => {
std::fs::create_dir_all(dir).map_err(|e| io(dir, e))?;
let path = dir.join(CATALOG);
if path.exists() {
outln!("{} is a registry already", dir.display());
} else {
write(&path, &catalog)?;
let readme = dir.join("README.md");
if !readme.exists() {
write(&readme, README)?;
}
outln!("Made the registry {name} in {}", dir.display());
}
outln!(
"Publish to it: lx publish <package folder> --to {}\n\
Use it here: lx catalog add {} --name {name}\n\
Host it: serve the folder from any static host, then\n \
lx catalog add https://<where>/{CATALOG} --name {name}",
dir.display(),
host.catalog_location(),
);
}
Host::GitHub(repo) => {
if gh(&["repo", "view", repo, "--json", "name"]).is_err() {
let visibility = if private { "--private" } else { "--public" };
gh(&[
"repo",
"create",
repo,
visibility,
"--add-readme",
"--description",
"A logoxide registry: each package release is a GitHub release",
])?;
outln!("Made the repository github.com/{repo}");
}
if gh(&["release", "view", INDEX, "-R", repo]).is_ok() {
outln!("github.com/{repo} is a registry already");
} else {
let dir = scratch("registry")?;
let path = dir.join(CATALOG);
write(&path, &catalog)?;
gh(&[
"release",
"create",
INDEX,
&path.display().to_string(),
"-R",
repo,
"--title",
"Registry index",
"--notes",
"This release's catalog.toml lists every package and release here (lx publish updates it).",
"--latest=false",
])?;
let _ = std::fs::remove_dir_all(&dir);
outln!("Made the registry {name} in github.com/{repo}'s releases");
}
outln!(
"Publish to it: lx publish <package folder> --to github:{repo}\n\
Use it: lx catalog add {} --name {name}",
host.catalog_location()
);
}
}
Ok(())
}
struct Packed {
info: logoxide::manifest::Package,
bytes: Vec<u8>,
checksum: String,
path: Option<String>,
}
fn pack(dir: &Path, git: &logoxide::git::GitCli) -> logoxide::Result<Packed> {
let (root, prefix) = git.package_in(dir)?;
let folder = logoxide::FsStorage::new(dir).no_symlinks();
let manifest = logoxide::Manifest::parse(&folder.read_string(logoxide::manifest::MANIFEST)?)?;
let version = (manifest.package.as_ref())
.map(|p| p.version.clone())
.ok_or_else(|| LogoxideError::Manifest(format!("{}: no [package]", dir.display())))?;
let tag = logoxide::release::tag_for(&prefix, &version);
if !git.local_versions(dir)?.iter().any(|(_, t)| *t == tag) {
return Err(LogoxideError::Resolve(format!(
"{} has no release {tag}: lx release first (a published version is a release)",
dir.display()
)));
}
let all = MemoryStorage::new();
git.fetch(&root.display().to_string(), &GitRef::Tag(tag), &all)?;
let files = logoxide::package::only_declared(&logoxide::release::within(&all, &prefix)?)?;
let info = logoxide::Manifest::parse(&files.read_string(logoxide::manifest::MANIFEST)?)?
.package
.ok_or_else(|| LogoxideError::Manifest(String::from("no [package] at its release")))?;
Ok(Packed {
bytes: publish::pack(&files, &info)?,
checksum: logoxide::package::checksum(&files)?,
info,
path: Some(prefix.trim_end_matches('/').to_string()).filter(|p| !p.is_empty()),
})
}
fn confirm_license(info: &logoxide::manifest::Package, yes: bool) -> logoxide::Result<bool> {
let concerns = logoxide::license::assess(info);
for concern in concerns.iter().filter(|c| !c.is_serious()) {
errln!("warning: {}", concern.message);
}
let Some(serious) = concerns.iter().find(|c| c.is_serious()) else {
return Ok(true);
};
errln!("");
errln!("WARNING: {}", serious.message);
errln!("");
if yes {
return Ok(true);
}
use std::io::IsTerminal;
if !std::io::stdin().is_terminal() {
return Err(LogoxideError::Catalog(format!(
"{} {} wasn't published: pass --yes to publish it anyway",
info.id, info.version
)));
}
eprint!("Publish {} {} anyway? [y/N] ", info.id, info.version);
let mut answer = String::new();
let _ = std::io::stdin().read_line(&mut answer);
let publish = matches!(answer.trim(), "y" | "Y" | "yes");
if !publish {
outln!("{} {}: not published", info.id, info.version);
}
Ok(publish)
}
pub fn publish(
dir: &Path,
host: &Host,
git: &logoxide::git::GitCli,
yes: bool,
) -> logoxide::Result<()> {
let packed = pack(dir, git)?;
let info = &packed.info;
if !confirm_license(info, yes)? {
return Ok(());
}
let file = publish::archive_name(info);
let sha256 = publish::sha256(&packed.bytes);
let release = |url: String| CatalogRelease {
version: info.version.to_string(),
url,
mirrors: vec![],
sha256: sha256.clone(),
checksum: Some(packed.checksum.clone()),
size: Some(packed.bytes.len() as u64),
yanked: false,
};
let label = format!("{} {}", info.id, info.version);
let size = packed.bytes.len() as f64 / 1_000_000.0;
match host {
Host::Folder(registry) => {
let catalog = registry.join(CATALOG);
let text = std::fs::read_to_string(&catalog).map_err(|_| {
LogoxideError::Catalog(format!(
"{} isn't a registry (lx registry init {})",
registry.display(),
registry.display()
))
})?;
let relative = format!("{}/{file}", info.id);
let release = release(relative.clone());
let Some(updated) = publish::add_release(
Some(&text),
&host.default_name(),
info,
packed.path.as_deref(),
&release,
)?
else {
outln!("{label} is published there already");
return Ok(());
};
let archive = registry.join(&relative);
std::fs::create_dir_all(registry.join(&info.id)).map_err(|e| io(registry, e))?;
write(&archive, &packed.bytes)?;
write(&catalog, updated)?;
outln!("Published {label} ({size:.1} MB) to {}", registry.display());
}
Host::GitHub(repo) => {
let work = scratch("publish")?;
gh(&[
"release",
"download",
INDEX,
"-R",
repo,
"-p",
CATALOG,
"-D",
&work.display().to_string(),
"--clobber",
])
.map_err(|e| {
LogoxideError::Catalog(format!(
"github.com/{repo} isn't a registry yet (lx registry init github:{repo}): {e}"
))
})?;
let catalog = work.join(CATALOG);
let text = std::fs::read_to_string(&catalog).map_err(|e| io(&catalog, e))?;
let tag = format!("{}-v{}", info.id, info.version);
let url = format!("https://github.com/{repo}/releases/download/{tag}/{file}");
let Some(updated) = publish::add_release(
Some(&text),
&host.default_name(),
info,
packed.path.as_deref(),
&release(url),
)?
else {
outln!("{label} is published there already");
return Ok(());
};
let archive = work.join(&file);
write(&archive, &packed.bytes)?;
let archive = archive.display().to_string();
if gh(&["release", "view", &tag, "-R", repo]).is_ok() {
gh(&["release", "upload", &tag, &archive, "-R", repo, "--clobber"])?;
} else {
gh(&[
"release",
"create",
&tag,
&archive,
"-R",
repo,
"--title",
&format!("{} {}", info.name, info.version),
"--notes",
&format!("{label}: {sha256}"),
"--latest=false",
])?;
}
write(&catalog, updated)?;
gh(&[
"release",
"upload",
INDEX,
&catalog.display().to_string(),
"-R",
repo,
"--clobber",
])?;
let _ = std::fs::remove_dir_all(&work);
outln!("Published {label} ({size:.1} MB) to github.com/{repo}");
}
}
outln!(
"Readers add it with: lx catalog add {} --name <name>, then lx add <name>:{}",
host.catalog_location(),
packed.path.as_deref().unwrap_or(&info.id)
);
Ok(())
}
pub fn publish_all(
dir: &Path,
host: &Host,
git: &logoxide::git::GitCli,
yes: bool,
) -> logoxide::Result<()> {
let mut found = vec![];
let mut pending = vec![dir.to_path_buf()];
while let Some(folder) = pending.pop() {
if folder.join(logoxide::manifest::MANIFEST).is_file() {
found.push(folder.clone());
}
let entries = std::fs::read_dir(&folder).map_err(|e| io(&folder, e))?;
for entry in entries.flatten() {
let hidden = entry.file_name().to_string_lossy().starts_with('.');
if !hidden && entry.file_type().is_ok_and(|t| t.is_dir()) {
pending.push(entry.path());
}
}
}
found.sort();
for package in found {
match publish(&package, host, git, yes) {
Err(LogoxideError::Resolve(message)) if message.contains("lx release first") => {
outln!("{}: not released yet, passed over", package.display())
}
Err(LogoxideError::Manifest(message)) if message.contains("no [package]") => {}
result => result?,
}
}
Ok(())
}
pub fn yank(host: &Host, id: &str, version: &str, undo: bool) -> logoxide::Result<()> {
let what = if undo { "Put back" } else { "Yanked" };
match host {
Host::Folder(registry) => {
let catalog = registry.join(CATALOG);
let text = std::fs::read_to_string(&catalog).map_err(|e| io(&catalog, e))?;
let updated = publish::yank(&text, id, version, !undo)?;
write(&catalog, updated)?;
outln!("{what} {id} {version} in {}", registry.display());
}
Host::GitHub(repo) => {
let work = scratch("yank")?;
gh(&[
"release",
"download",
INDEX,
"-R",
repo,
"-p",
CATALOG,
"-D",
&work.display().to_string(),
"--clobber",
])?;
let catalog = work.join(CATALOG);
let text = std::fs::read_to_string(&catalog).map_err(|e| io(&catalog, e))?;
write(&catalog, publish::yank(&text, id, version, !undo)?)?;
gh(&[
"release",
"upload",
INDEX,
&catalog.display().to_string(),
"-R",
repo,
"--clobber",
])?;
let _ = std::fs::remove_dir_all(&work);
outln!("{what} {id} {version} in github.com/{repo}");
}
}
outln!("Lock files that have it still install it; new installs and updates pass over it");
Ok(())
}