From 325453450b1da4da12a6721ed75d2add4ec63800 Mon Sep 17 00:00:00 2001 From: daimond113 <72147841+daimond113@users.noreply.github.com> Date: Thu, 9 Jan 2025 22:09:28 +0100 Subject: [PATCH] feat: add deprecating & yanking --- Cargo.lock | 7 + Cargo.toml | 1 + docs/src/content/docs/guides/publishing.mdx | 7 + registry/CHANGELOG.md | 7 + registry/src/endpoints/deprecate_version.rs | 76 ++++++ registry/src/endpoints/mod.rs | 2 + registry/src/endpoints/package_version.rs | 132 ++------- registry/src/endpoints/package_versions.rs | 51 +--- registry/src/endpoints/publish_version.rs | 181 ++++--------- registry/src/endpoints/search.rs | 51 ++-- registry/src/endpoints/yank_version.rs | 83 ++++++ registry/src/error.rs | 16 +- registry/src/git.rs | 98 +++++++ registry/src/main.rs | 14 + registry/src/package.rs | 255 ++++++++++++++++-- registry/src/request_path.rs | 69 +++++ registry/src/search.rs | 62 ++++- registry/src/storage/fs.rs | 19 +- registry/src/storage/mod.rs | 26 +- registry/src/storage/s3.rs | 14 +- src/cli/commands/auth/mod.rs | 37 +-- src/cli/commands/deprecate.rs | 111 ++++++++ src/cli/commands/mod.rs | 10 + src/cli/commands/yank.rs | 157 +++++++++++ src/cli/mod.rs | 38 ++- src/names.rs | 10 + src/source/pesde/mod.rs | 20 +- website/src/lib/registry-api.ts | 48 +++- .../packages/[scope]/[name]/+layout.svelte | 9 + .../[scope]/[name]/TargetSelector.svelte | 9 + .../[[version]]/[[target]]/(docs)/+layout.ts | 35 +-- .../[[target]]/(docs)/TargetSelector.svelte | 8 +- .../[[target]]/dependencies/+page.svelte | 24 +- .../[scope]/[name]/versions/+page.svelte | 22 +- .../packages/[scope]/[name]/versions/+page.ts | 18 +- 35 files changed, 1259 insertions(+), 468 deletions(-) create mode 100644 registry/src/endpoints/deprecate_version.rs create mode 100644 registry/src/endpoints/yank_version.rs create mode 100644 registry/src/git.rs create mode 100644 registry/src/request_path.rs create mode 100644 src/cli/commands/deprecate.rs create mode 100644 src/cli/commands/yank.rs diff --git a/Cargo.lock b/Cargo.lock index 4f551d8..46a1900 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3693,6 +3693,7 @@ dependencies = [ "tracing", "tracing-subscriber", "url", + "urlencoding", "wax", "winreg", ] @@ -5461,6 +5462,12 @@ dependencies = [ "serde", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "utf16_iter" version = "1.0.5" diff --git a/Cargo.toml b/Cargo.toml index 907821b..5f5c616 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -67,6 +67,7 @@ sha2 = "0.10.8" tempfile = "3.14.0" wax = { version = "0.6.0", default-features = false } fs-err = { version = "3.0.0", features = ["tokio"] } +urlencoding = "2.1.3" # TODO: remove this when gitoxide adds support for: committing, pushing, adding git2 = { version = "0.19.0", optional = true } diff --git a/docs/src/content/docs/guides/publishing.mdx b/docs/src/content/docs/guides/publishing.mdx index 9bdc6d4..99d8d30 100644 --- a/docs/src/content/docs/guides/publishing.mdx +++ b/docs/src/content/docs/guides/publishing.mdx @@ -91,6 +91,13 @@ For example, you may publish a package that can be used in both Roblox and Luau environments by publishing two versions of the package, one for each environment. + + ## Documentation The `README.md` file in the root of the package will be displayed on the diff --git a/registry/CHANGELOG.md b/registry/CHANGELOG.md index 1649c01..37e577d 100644 --- a/registry/CHANGELOG.md +++ b/registry/CHANGELOG.md @@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] +### Added +- Support deprecating and yanking packages by @daimond113 + +### Changed +- Asyncify blocking operations by @daimond113 + ## [0.1.2] ### Changed - Update to pesde lib API changes by @daimond113 diff --git a/registry/src/endpoints/deprecate_version.rs b/registry/src/endpoints/deprecate_version.rs new file mode 100644 index 0000000..432780a --- /dev/null +++ b/registry/src/endpoints/deprecate_version.rs @@ -0,0 +1,76 @@ +use crate::{ + auth::UserId, + error::{ErrorResponse, RegistryError}, + git::push_changes, + package::{read_package, read_scope_info}, + search::search_version_changed, + AppState, +}; +use actix_web::{http::Method, web, HttpRequest, HttpResponse}; +use pesde::names::PackageName; +use std::collections::HashMap; + +pub async fn deprecate_package_version( + request: HttpRequest, + app_state: web::Data, + path: web::Path, + bytes: web::Bytes, + user_id: web::ReqData, +) -> Result { + let deprecated = request.method() != Method::DELETE; + let reason = if deprecated { + match String::from_utf8(bytes.to_vec()).map(|s| s.trim().to_string()) { + Ok(reason) if !reason.is_empty() => reason, + Err(e) => { + return Ok(HttpResponse::BadRequest().json(ErrorResponse { + error: format!("invalid utf-8: {e}"), + })) + } + _ => { + return Ok(HttpResponse::BadRequest().json(ErrorResponse { + error: "deprecating must have a non-empty reason".to_string(), + })) + } + } + } else { + String::new() + }; + let name = path.into_inner(); + let source = app_state.source.lock().await; + + let Some(scope_info) = read_scope_info(&app_state, name.scope(), &source).await? else { + return Ok(HttpResponse::NotFound().finish()); + }; + + if !scope_info.owners.contains(&user_id.0) { + return Ok(HttpResponse::Forbidden().finish()); + } + + let Some(mut file) = read_package(&app_state, &name, &source).await? else { + return Ok(HttpResponse::NotFound().finish()); + }; + + if file.meta.deprecated == reason { + return Ok(HttpResponse::Conflict().finish()); + } + + file.meta.deprecated = reason; + + let file_string = toml::to_string(&file)?; + + push_changes( + &app_state, + &source, + name.scope().to_string(), + HashMap::from([(name.name().to_string(), file_string.into_bytes())]), + format!("{}deprecate {name}", if deprecated { "" } else { "un" },), + ) + .await?; + + search_version_changed(&app_state, &name, &file); + + Ok(HttpResponse::Ok().body(format!( + "{}deprecated {name}", + if deprecated { "" } else { "un" }, + ))) +} diff --git a/registry/src/endpoints/mod.rs b/registry/src/endpoints/mod.rs index be8e8fa..0870413 100644 --- a/registry/src/endpoints/mod.rs +++ b/registry/src/endpoints/mod.rs @@ -1,4 +1,6 @@ +pub mod deprecate_version; pub mod package_version; pub mod package_versions; pub mod publish_version; pub mod search; +pub mod yank_version; diff --git a/registry/src/endpoints/package_version.rs b/registry/src/endpoints/package_version.rs index e27d5ae..2c52439 100644 --- a/registry/src/endpoints/package_version.rs +++ b/registry/src/endpoints/package_version.rs @@ -1,60 +1,14 @@ -use actix_web::{http::header::ACCEPT, web, HttpRequest, HttpResponse, Responder}; -use semver::Version; -use serde::{Deserialize, Deserializer}; +use actix_web::{http::header::ACCEPT, web, HttpRequest, HttpResponse}; +use serde::Deserialize; -use crate::{error::Error, package::PackageResponse, storage::StorageImpl, AppState}; -use pesde::{ - manifest::target::TargetKind, - names::PackageName, - source::{ - git_index::{read_file, root_tree, GitBasedSource}, - pesde::{DocEntryKind, IndexFile}, - }, +use crate::{ + error::RegistryError, + package::{read_package, PackageResponse}, + request_path::{AnyOrSpecificTarget, LatestOrSpecificVersion}, + storage::StorageImpl, + AppState, }; - -#[derive(Debug)] -pub enum VersionRequest { - Latest, - Specific(Version), -} - -impl<'de> Deserialize<'de> for VersionRequest { - fn deserialize(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - let s = String::deserialize(deserializer)?; - if s.eq_ignore_ascii_case("latest") { - return Ok(VersionRequest::Latest); - } - - s.parse() - .map(VersionRequest::Specific) - .map_err(serde::de::Error::custom) - } -} - -#[derive(Debug)] -pub enum TargetRequest { - Any, - Specific(TargetKind), -} - -impl<'de> Deserialize<'de> for TargetRequest { - fn deserialize(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - let s = String::deserialize(deserializer)?; - if s.eq_ignore_ascii_case("any") { - return Ok(TargetRequest::Any); - } - - s.parse() - .map(TargetRequest::Specific) - .map_err(serde::de::Error::custom) - } -} +use pesde::{names::PackageName, source::pesde::DocEntryKind}; #[derive(Debug, Deserialize)] pub struct Query { @@ -64,65 +18,50 @@ pub struct Query { pub async fn get_package_version( request: HttpRequest, app_state: web::Data, - path: web::Path<(PackageName, VersionRequest, TargetRequest)>, - query: web::Query, -) -> Result { + path: web::Path<(PackageName, LatestOrSpecificVersion, AnyOrSpecificTarget)>, + request_query: web::Query, +) -> Result { let (name, version, target) = path.into_inner(); - let (scope, name_part) = name.as_str(); - - let file: IndexFile = { - let source = app_state.source.lock().await; - let repo = gix::open(source.path(&app_state.project))?; - let tree = root_tree(&repo)?; - - match read_file(&tree, [scope, name_part])? { - Some(versions) => toml::de::from_str(&versions)?, - None => return Ok(HttpResponse::NotFound().finish()), - } + let Some(file) = read_package(&app_state, &name, &*app_state.source.lock().await).await? else { + return Ok(HttpResponse::NotFound().finish()); }; - let Some((v_id, entry, targets)) = ({ + let Some((v_id, entry)) = ({ let version = match version { - VersionRequest::Latest => match file.entries.keys().map(|k| k.version()).max() { + LatestOrSpecificVersion::Latest => match file.entries.keys().map(|k| k.version()).max() + { Some(latest) => latest.clone(), None => return Ok(HttpResponse::NotFound().finish()), }, - VersionRequest::Specific(version) => version, + LatestOrSpecificVersion::Specific(version) => version, }; - let versions = file + let mut versions = file .entries .iter() .filter(|(v_id, _)| *v_id.version() == version); match target { - TargetRequest::Any => versions.clone().min_by_key(|(v_id, _)| *v_id.target()), - TargetRequest::Specific(kind) => versions - .clone() - .find(|(_, entry)| entry.target.kind() == kind), + AnyOrSpecificTarget::Any => versions.min_by_key(|(v_id, _)| *v_id.target()), + AnyOrSpecificTarget::Specific(kind) => { + versions.find(|(_, entry)| entry.target.kind() == kind) + } } - .map(|(v_id, entry)| { - ( - v_id, - entry, - versions.map(|(_, entry)| (&entry.target).into()).collect(), - ) - }) }) else { return Ok(HttpResponse::NotFound().finish()); }; - if let Some(doc_name) = query.doc.as_deref() { + if let Some(doc_name) = request_query.doc.as_deref() { let hash = 'finder: { - let mut hash = entry.docs.iter().map(|doc| &doc.kind).collect::>(); - while let Some(doc) = hash.pop() { + let mut queue = entry.docs.iter().map(|doc| &doc.kind).collect::>(); + while let Some(doc) = queue.pop() { match doc { DocEntryKind::Page { name, hash } if name == doc_name => { break 'finder hash.clone() } DocEntryKind::Category { items, .. } => { - hash.extend(items.iter().map(|item| &item.kind)) + queue.extend(items.iter().map(|item| &item.kind)) } _ => continue, }; @@ -152,20 +91,5 @@ pub async fn get_package_version( }; } - let response = PackageResponse { - name: name.to_string(), - version: v_id.version().to_string(), - targets, - description: entry.description.clone().unwrap_or_default(), - published_at: entry.published_at, - license: entry.license.clone().unwrap_or_default(), - authors: entry.authors.clone(), - repository: entry.repository.clone().map(|url| url.to_string()), - }; - - let mut value = serde_json::to_value(response)?; - value["docs"] = serde_json::to_value(entry.docs.clone())?; - value["dependencies"] = serde_json::to_value(entry.dependencies.clone())?; - - Ok(HttpResponse::Ok().json(value)) + Ok(HttpResponse::Ok().json(PackageResponse::new(&name, v_id, &file))) } diff --git a/registry/src/endpoints/package_versions.rs b/registry/src/endpoints/package_versions.rs index d5e3378..2a7b180 100644 --- a/registry/src/endpoints/package_versions.rs +++ b/registry/src/endpoints/package_versions.rs @@ -1,54 +1,21 @@ -use std::collections::{BTreeMap, BTreeSet}; - use actix_web::{web, HttpResponse, Responder}; -use crate::{error::Error, package::PackageResponse, AppState}; -use pesde::{ - names::PackageName, - source::{ - git_index::{read_file, root_tree, GitBasedSource}, - pesde::IndexFile, - }, +use crate::{ + error::RegistryError, + package::{read_package, PackageVersionsResponse}, + AppState, }; +use pesde::names::PackageName; pub async fn get_package_versions( app_state: web::Data, path: web::Path, -) -> Result { +) -> Result { let name = path.into_inner(); - let (scope, name_part) = name.as_str(); - - let file: IndexFile = { - let source = app_state.source.lock().await; - let repo = gix::open(source.path(&app_state.project))?; - let tree = root_tree(&repo)?; - - match read_file(&tree, [scope, name_part])? { - Some(versions) => toml::de::from_str(&versions)?, - None => return Ok(HttpResponse::NotFound().finish()), - } + let Some(file) = read_package(&app_state, &name, &*app_state.source.lock().await).await? else { + return Ok(HttpResponse::NotFound().finish()); }; - let mut responses = BTreeMap::new(); - - for (v_id, entry) in file.entries { - let info = responses - .entry(v_id.version().clone()) - .or_insert_with(|| PackageResponse { - name: name.to_string(), - version: v_id.version().to_string(), - targets: BTreeSet::new(), - description: entry.description.unwrap_or_default(), - published_at: entry.published_at, - license: entry.license.unwrap_or_default(), - authors: entry.authors.clone(), - repository: entry.repository.clone().map(|url| url.to_string()), - }); - - info.targets.insert(entry.target.into()); - info.published_at = info.published_at.max(entry.published_at); - } - - Ok(HttpResponse::Ok().json(responses.into_values().collect::>())) + Ok(HttpResponse::Ok().json(PackageVersionsResponse::new(&name, &file))) } diff --git a/registry/src/endpoints/publish_version.rs b/registry/src/endpoints/publish_version.rs index cba2c3f..9ca0625 100644 --- a/registry/src/endpoints/publish_version.rs +++ b/registry/src/endpoints/publish_version.rs @@ -1,22 +1,22 @@ use crate::{ auth::UserId, - benv, - error::{Error, ErrorResponse}, - search::update_version, + error::{ErrorResponse, RegistryError}, + git::push_changes, + package::{read_package, read_scope_info}, + search::update_search_version, storage::StorageImpl, AppState, }; -use actix_web::{web, web::Bytes, HttpResponse, Responder}; +use actix_web::{web, web::Bytes, HttpResponse}; use async_compression::Level; use convert_case::{Case, Casing}; use fs_err::tokio as fs; -use git2::{Remote, Repository, Signature}; use pesde::{ manifest::Manifest, source::{ - git_index::{read_file, root_tree, GitBasedSource}, + git_index::GitBasedSource, ids::VersionId, - pesde::{DocEntry, DocEntryKind, IndexFile, IndexFileEntry, ScopeInfo, SCOPE_INFO_FILE}, + pesde::{DocEntry, DocEntryKind, IndexFileEntry, ScopeInfo, SCOPE_INFO_FILE}, specifiers::DependencySpecifiers, traits::RefreshOptions, IGNORED_DIRS, IGNORED_FILES, @@ -28,35 +28,13 @@ use serde::Deserialize; use sha2::{Digest, Sha256}; use std::{ collections::{BTreeSet, HashMap}, - io::{Cursor, Write}, + io::Cursor, }; use tokio::{ io::{AsyncReadExt, AsyncWriteExt}, task::JoinSet, }; -fn signature<'a>() -> Signature<'a> { - Signature::now( - &benv!(required "COMMITTER_GIT_NAME"), - &benv!(required "COMMITTER_GIT_EMAIL"), - ) - .unwrap() -} - -fn get_refspec(repo: &Repository, remote: &mut Remote) -> Result { - let upstream_branch_buf = repo.branch_upstream_name(repo.head()?.name().unwrap())?; - let upstream_branch = upstream_branch_buf.as_str().unwrap(); - - let refspec_buf = remote - .refspecs() - .find(|r| r.direction() == git2::Direction::Fetch && r.dst_matches(upstream_branch)) - .unwrap() - .rtransform(upstream_branch)?; - let refspec = refspec_buf.as_str().unwrap(); - - Ok(refspec.to_string()) -} - const ADDITIONAL_FORBIDDEN_FILES: &[&str] = &["default.project.json"]; #[derive(Debug, Deserialize, Default)] @@ -73,7 +51,7 @@ pub async fn publish_package( app_state: web::Data, bytes: Bytes, user_id: web::ReqData, -) -> Result { +) -> Result { let source = app_state.source.lock().await; source .refresh(&RefreshOptions { @@ -102,12 +80,14 @@ pub async fn publish_package( let file_name = entry .file_name() .to_str() - .ok_or_else(|| Error::InvalidArchive("file name contains non UTF-8 characters".into()))? + .ok_or_else(|| { + RegistryError::InvalidArchive("file name contains non UTF-8 characters".into()) + })? .to_string(); if entry.file_type().await?.is_dir() { if IGNORED_DIRS.contains(&file_name.as_str()) { - return Err(Error::InvalidArchive(format!( + return Err(RegistryError::InvalidArchive(format!( "archive contains forbidden directory: {file_name}" ))); } @@ -125,7 +105,7 @@ pub async fn publish_package( .file_name() .to_str() .ok_or_else(|| { - Error::InvalidArchive( + RegistryError::InvalidArchive( "file name contains non UTF-8 characters".into(), ) })? @@ -192,7 +172,7 @@ pub async fn publish_package( let info: DocEntryInfo = serde_yaml::from_str(&front_matter).map_err(|_| { - Error::InvalidArchive(format!( + RegistryError::InvalidArchive(format!( "doc {file_name}'s frontmatter isn't valid YAML" )) })?; @@ -208,7 +188,7 @@ pub async fn publish_package( .with_extension("") .to_str() .ok_or_else(|| { - Error::InvalidArchive( + RegistryError::InvalidArchive( "file name contains non UTF-8 characters".into(), ) })? @@ -248,7 +228,7 @@ pub async fn publish_package( if IGNORED_FILES.contains(&file_name.as_str()) || ADDITIONAL_FORBIDDEN_FILES.contains(&file_name.as_str()) { - return Err(Error::InvalidArchive(format!( + return Err(RegistryError::InvalidArchive(format!( "archive contains forbidden file: {file_name}" ))); } @@ -264,7 +244,7 @@ pub async fn publish_package( .is_some() { if readme.is_some() { - return Err(Error::InvalidArchive( + return Err(RegistryError::InvalidArchive( "archive contains multiple readme files".into(), )); } @@ -279,7 +259,7 @@ pub async fn publish_package( } let Some(manifest) = manifest else { - return Err(Error::InvalidArchive( + return Err(RegistryError::InvalidArchive( "archive doesn't contain a manifest".into(), )); }; @@ -300,7 +280,7 @@ pub async fn publish_package( { let dependencies = manifest.all_dependencies().map_err(|e| { - Error::InvalidArchive(format!("manifest has invalid dependencies: {e}")) + RegistryError::InvalidArchive(format!("manifest has invalid dependencies: {e}")) })?; for (specifier, _) in dependencies.values() { @@ -317,7 +297,7 @@ pub async fn publish_package( }) .is_none() { - return Err(Error::InvalidArchive(format!( + return Err(RegistryError::InvalidArchive(format!( "invalid index in pesde dependency {specifier}" ))); } @@ -332,43 +312,37 @@ pub async fn publish_package( }) .is_none() { - return Err(Error::InvalidArchive(format!( + return Err(RegistryError::InvalidArchive(format!( "invalid index in wally dependency {specifier}" ))); } } DependencySpecifiers::Git(specifier) => { if !config.git_allowed.is_allowed(specifier.repo.clone()) { - return Err(Error::InvalidArchive( + return Err(RegistryError::InvalidArchive( "git dependencies are not allowed".into(), )); } } DependencySpecifiers::Workspace(_) => { // workspace specifiers are to be transformed into pesde specifiers by the sender - return Err(Error::InvalidArchive( + return Err(RegistryError::InvalidArchive( "non-transformed workspace dependency".into(), )); } DependencySpecifiers::Path(_) => { - return Err(Error::InvalidArchive( + return Err(RegistryError::InvalidArchive( "path dependencies are not allowed".into(), )); } } } - let repo = Repository::open_bare(source.path(&app_state.project))?; - let gix_repo = gix::open(repo.path())?; + let mut files = HashMap::new(); - let gix_tree = root_tree(&gix_repo)?; - - let (scope, name) = manifest.name.as_str(); - let mut oids = vec![]; - - match read_file(&gix_tree, [scope, SCOPE_INFO_FILE])? { + let scope = read_scope_info(&app_state, manifest.name.scope(), &source).await?; + match scope { Some(info) => { - let info: ScopeInfo = toml::de::from_str(&info)?; if !info.owners.contains(&user_id.0) { return Ok(HttpResponse::Forbidden().finish()); } @@ -378,14 +352,13 @@ pub async fn publish_package( owners: BTreeSet::from([user_id.0]), })?; - let mut blob_writer = repo.blob_writer(None)?; - blob_writer.write_all(scope_info.as_bytes())?; - oids.push((SCOPE_INFO_FILE, blob_writer.commit()?)); + files.insert(SCOPE_INFO_FILE.to_string(), scope_info.into_bytes()); } - }; + } - let mut file: IndexFile = - toml::de::from_str(&read_file(&gix_tree, [scope, name])?.unwrap_or_default())?; + let mut file = read_package(&app_state, &manifest.name, &source) + .await? + .unwrap_or_default(); let new_entry = IndexFileEntry { target: manifest.target.clone(), @@ -394,28 +367,21 @@ pub async fn publish_package( license: manifest.license.clone(), authors: manifest.authors.clone(), repository: manifest.repository.clone(), + yanked: false, docs, dependencies, }; - let this_version = file + let same_version = file .entries - .keys() - .find(|v_id| *v_id.version() == manifest.version); - if let Some(this_version) = this_version { - let other_entry = file.entries.get(this_version).unwrap(); - + .iter() + .find(|(v_id, _)| *v_id.version() == manifest.version); + if let Some((_, other_entry)) = same_version { // description cannot be different - which one to render in the "Recently published" list? - // the others cannot be different because what to return from the versions endpoint? - if other_entry.description != new_entry.description - || other_entry.license != new_entry.license - || other_entry.authors != new_entry.authors - || other_entry.repository != new_entry.repository - { + if other_entry.description != new_entry.description { return Ok(HttpResponse::BadRequest().json(ErrorResponse { - error: "same version with different description or license already exists" - .to_string(), + error: "same versions with different descriptions are forbidden".to_string(), })); } } @@ -431,60 +397,24 @@ pub async fn publish_package( return Ok(HttpResponse::Conflict().finish()); } - let mut remote = repo.find_remote("origin")?; - let refspec = get_refspec(&repo, &mut remote)?; + files.insert( + manifest.name.name().to_string(), + toml::to_string(&file)?.into_bytes(), + ); - let reference = repo.find_reference(&refspec)?; - - { - let index_content = toml::to_string(&file)?; - let mut blob_writer = repo.blob_writer(None)?; - blob_writer.write_all(index_content.as_bytes())?; - oids.push((name, blob_writer.commit()?)); - } - - let old_root_tree = reference.peel_to_tree()?; - let old_scope_tree = match old_root_tree.get_name(scope) { - Some(entry) => Some(repo.find_tree(entry.id())?), - None => None, - }; - - let mut scope_tree = repo.treebuilder(old_scope_tree.as_ref())?; - for (file, oid) in oids { - scope_tree.insert(file, oid, 0o100644)?; - } - - let scope_tree_id = scope_tree.write()?; - let mut root_tree = repo.treebuilder(Some(&repo.find_tree(old_root_tree.id())?))?; - root_tree.insert(scope, scope_tree_id, 0o040000)?; - - let tree_oid = root_tree.write()?; - - repo.commit( - Some("HEAD"), - &signature(), - &signature(), - &format!( + push_changes( + &app_state, + &source, + manifest.name.scope().to_string(), + files, + format!( "add {}@{} {}", manifest.name, manifest.version, manifest.target ), - &repo.find_tree(tree_oid)?, - &[&reference.peel_to_commit()?], - )?; + ) + .await?; - let mut push_options = git2::PushOptions::new(); - let mut remote_callbacks = git2::RemoteCallbacks::new(); - - let git_creds = app_state.project.auth_config().git_credentials().unwrap(); - remote_callbacks.credentials(|_, _, _| { - git2::Cred::userpass_plaintext(&git_creds.username, &git_creds.password) - }); - - push_options.remote_callbacks(remote_callbacks); - - remote.push(&[refspec], Some(&mut push_options))?; - - update_version(&app_state, &manifest.name, new_entry); + update_search_version(&app_state, &manifest.name, &manifest.version, &new_entry); } let version_id = VersionId::new(manifest.version.clone(), manifest.target.kind()); @@ -527,8 +457,5 @@ pub async fn publish_package( res.unwrap()?; } - Ok(HttpResponse::Ok().body(format!( - "published {}@{} {}", - manifest.name, manifest.version, manifest.target - ))) + Ok(HttpResponse::Ok().body(format!("published {}@{version_id}", manifest.name))) } diff --git a/registry/src/endpoints/search.rs b/registry/src/endpoints/search.rs index 6bd5ae1..c063fa1 100644 --- a/registry/src/endpoints/search.rs +++ b/registry/src/endpoints/search.rs @@ -1,10 +1,11 @@ use std::collections::HashMap; -use actix_web::{web, HttpResponse, Responder}; +use actix_web::{web, HttpResponse}; +use semver::Version; use serde::Deserialize; use tantivy::{collector::Count, query::AllQuery, schema::Value, DateTime, Order}; -use crate::{error::Error, package::PackageResponse, AppState}; +use crate::{error::RegistryError, package::PackageResponse, AppState}; use pesde::{ names::PackageName, source::{ @@ -18,19 +19,20 @@ pub struct Request { #[serde(default)] query: Option, #[serde(default)] - offset: Option, + offset: usize, } pub async fn search_packages( app_state: web::Data, - request: web::Query, -) -> Result { + request_query: web::Query, +) -> Result { let searcher = app_state.search_reader.searcher(); let schema = searcher.schema(); let id = schema.get_field("id").unwrap(); + let version = schema.get_field("version").unwrap(); - let query = request.query.as_deref().unwrap_or_default().trim(); + let query = request_query.query.as_deref().unwrap_or_default().trim(); let query = if query.is_empty() { Box::new(AllQuery) @@ -44,7 +46,7 @@ pub async fn search_packages( &( Count, tantivy::collector::TopDocs::with_limit(50) - .and_offset(request.offset.unwrap_or_default()) + .and_offset(request_query.offset) .order_by_fast_field::("published_at", Order::Desc), ), ) @@ -67,36 +69,25 @@ pub async fn search_packages( .parse::() .unwrap(); let (scope, name) = id.as_str(); + let version = doc + .get(&version) + .unwrap() + .as_str() + .unwrap() + .parse::() + .unwrap(); let file: IndexFile = toml::de::from_str(&read_file(&tree, [scope, name]).unwrap().unwrap()).unwrap(); - let (latest_version, entry) = file + let version_id = file .entries - .iter() - .max_by_key(|(v_id, _)| v_id.version()) + .keys() + .filter(|v_id| *v_id.version() == version) + .max() .unwrap(); - PackageResponse { - name: id.to_string(), - version: latest_version.version().to_string(), - targets: file - .entries - .iter() - .filter(|(v_id, _)| v_id.version() == latest_version.version()) - .map(|(_, entry)| (&entry.target).into()) - .collect(), - description: entry.description.clone().unwrap_or_default(), - published_at: file - .entries - .values() - .map(|entry| entry.published_at) - .max() - .unwrap(), - license: entry.license.clone().unwrap_or_default(), - authors: entry.authors.clone(), - repository: entry.repository.clone().map(|url| url.to_string()), - } + PackageResponse::new(&id, version_id, &file) }) .collect::>(); diff --git a/registry/src/endpoints/yank_version.rs b/registry/src/endpoints/yank_version.rs new file mode 100644 index 0000000..0be0769 --- /dev/null +++ b/registry/src/endpoints/yank_version.rs @@ -0,0 +1,83 @@ +use crate::{ + auth::UserId, + error::RegistryError, + git::push_changes, + package::{read_package, read_scope_info}, + request_path::AllOrSpecificTarget, + search::search_version_changed, + AppState, +}; +use actix_web::{http::Method, web, HttpRequest, HttpResponse}; +use pesde::names::PackageName; +use semver::Version; +use std::collections::HashMap; + +pub async fn yank_package_version( + request: HttpRequest, + app_state: web::Data, + path: web::Path<(PackageName, Version, AllOrSpecificTarget)>, + user_id: web::ReqData, +) -> Result { + let yanked = request.method() != Method::DELETE; + let (name, version, target) = path.into_inner(); + let source = app_state.source.lock().await; + + let Some(scope_info) = read_scope_info(&app_state, name.scope(), &source).await? else { + return Ok(HttpResponse::NotFound().finish()); + }; + + if !scope_info.owners.contains(&user_id.0) { + return Ok(HttpResponse::Forbidden().finish()); + } + + let Some(mut file) = read_package(&app_state, &name, &source).await? else { + return Ok(HttpResponse::NotFound().finish()); + }; + + let mut targets = vec![]; + + for (v_id, entry) in &mut file.entries { + if *v_id.version() != version { + continue; + } + + match target { + AllOrSpecificTarget::Specific(kind) if entry.target.kind() != kind => continue, + _ => {} + } + + if entry.yanked == yanked { + continue; + } + + targets.push(entry.target.kind().to_string()); + entry.yanked = yanked; + } + + if targets.is_empty() { + return Ok(HttpResponse::Conflict().finish()); + } + + let file_string = toml::to_string(&file)?; + + push_changes( + &app_state, + &source, + name.scope().to_string(), + HashMap::from([(name.name().to_string(), file_string.into_bytes())]), + format!( + "{}yank {name}@{version} {}", + if yanked { "" } else { "un" }, + targets.join(", "), + ), + ) + .await?; + + search_version_changed(&app_state, &name, &file); + + Ok(HttpResponse::Ok().body(format!( + "{}yanked {name}@{version} {}", + if yanked { "" } else { "un" }, + targets.join(", "), + ))) +} diff --git a/registry/src/error.rs b/registry/src/error.rs index a42f78f..c7feb38 100644 --- a/registry/src/error.rs +++ b/registry/src/error.rs @@ -4,7 +4,7 @@ use serde::Serialize; use thiserror::Error; #[derive(Debug, Error)] -pub enum Error { +pub enum RegistryError { #[error("failed to parse query")] Query(#[from] tantivy::query::QueryParserError), @@ -53,16 +53,16 @@ pub struct ErrorResponse { pub error: String, } -impl ResponseError for Error { +impl ResponseError for RegistryError { fn error_response(&self) -> HttpResponse { match self { - Error::Query(e) => HttpResponse::BadRequest().json(ErrorResponse { + RegistryError::Query(e) => HttpResponse::BadRequest().json(ErrorResponse { error: format!("failed to parse query: {e}"), }), - Error::Tar(_) => HttpResponse::BadRequest().json(ErrorResponse { + RegistryError::Tar(_) => HttpResponse::BadRequest().json(ErrorResponse { error: "corrupt archive".to_string(), }), - Error::InvalidArchive(e) => HttpResponse::BadRequest().json(ErrorResponse { + RegistryError::InvalidArchive(e) => HttpResponse::BadRequest().json(ErrorResponse { error: format!("archive is invalid: {e}"), }), e => { @@ -74,16 +74,16 @@ impl ResponseError for Error { } pub trait ReqwestErrorExt { - async fn into_error(self) -> Result + async fn into_error(self) -> Result where Self: Sized; } impl ReqwestErrorExt for reqwest::Response { - async fn into_error(self) -> Result { + async fn into_error(self) -> Result { match self.error_for_status_ref() { Ok(_) => Ok(self), - Err(e) => Err(Error::ReqwestResponse(self.text().await?, e)), + Err(e) => Err(RegistryError::ReqwestResponse(self.text().await?, e)), } } } diff --git a/registry/src/git.rs b/registry/src/git.rs new file mode 100644 index 0000000..253b7f6 --- /dev/null +++ b/registry/src/git.rs @@ -0,0 +1,98 @@ +use crate::{benv, error::RegistryError, AppState}; +use git2::{Remote, Repository, Signature}; +use pesde::source::{git_index::GitBasedSource, pesde::PesdePackageSource}; +use std::collections::HashMap; +use tokio::task::spawn_blocking; + +fn signature<'a>() -> Signature<'a> { + Signature::now( + &benv!(required "COMMITTER_GIT_NAME"), + &benv!(required "COMMITTER_GIT_EMAIL"), + ) + .unwrap() +} + +fn get_refspec(repo: &Repository, remote: &mut Remote) -> Result { + let upstream_branch_buf = repo.branch_upstream_name(repo.head()?.name().unwrap())?; + let upstream_branch = upstream_branch_buf.as_str().unwrap(); + + let refspec_buf = remote + .refspecs() + .find(|r| r.direction() == git2::Direction::Fetch && r.dst_matches(upstream_branch)) + .unwrap() + .rtransform(upstream_branch)?; + let refspec = refspec_buf.as_str().unwrap(); + + Ok(refspec.to_string()) +} + +const FILE_FILEMODE: i32 = 0o100644; +const DIR_FILEMODE: i32 = 0o040000; + +pub async fn push_changes( + app_state: &AppState, + source: &PesdePackageSource, + directory: String, + files: HashMap>, + message: String, +) -> Result<(), RegistryError> { + let path = source.path(&app_state.project); + let auth_config = app_state.project.auth_config().clone(); + + spawn_blocking(move || { + let repo = Repository::open_bare(path)?; + let mut oids = HashMap::new(); + + let mut remote = repo.find_remote("origin")?; + let refspec = get_refspec(&repo, &mut remote)?; + + let reference = repo.find_reference(&refspec)?; + + for (name, contents) in files { + let oid = repo.blob(&contents)?; + oids.insert(name, oid); + } + + let old_root_tree = reference.peel_to_tree()?; + let old_dir_tree = match old_root_tree.get_name(&directory) { + Some(entry) => Some(repo.find_tree(entry.id())?), + None => None, + }; + + let mut dir_tree = repo.treebuilder(old_dir_tree.as_ref())?; + for (file, oid) in oids { + dir_tree.insert(file, oid, FILE_FILEMODE)?; + } + + let dir_tree_id = dir_tree.write()?; + let mut root_tree = repo.treebuilder(Some(&repo.find_tree(old_root_tree.id())?))?; + root_tree.insert(directory, dir_tree_id, DIR_FILEMODE)?; + + let tree_oid = root_tree.write()?; + + repo.commit( + Some("HEAD"), + &signature(), + &signature(), + &message, + &repo.find_tree(tree_oid)?, + &[&reference.peel_to_commit()?], + )?; + + let mut push_options = git2::PushOptions::new(); + let mut remote_callbacks = git2::RemoteCallbacks::new(); + + let git_creds = auth_config.git_credentials().unwrap(); + remote_callbacks.credentials(|_, _, _| { + git2::Cred::userpass_plaintext(&git_creds.username, &git_creds.password) + }); + + push_options.remote_callbacks(remote_callbacks); + + remote.push(&[refspec], Some(&mut push_options))?; + + Ok(()) + }) + .await + .unwrap() +} diff --git a/registry/src/main.rs b/registry/src/main.rs index 885bf5f..48583fa 100644 --- a/registry/src/main.rs +++ b/registry/src/main.rs @@ -29,7 +29,9 @@ use tracing_subscriber::{ mod auth; mod endpoints; mod error; +mod git; mod package; +mod request_path; mod search; mod storage; @@ -176,12 +178,24 @@ async fn run() -> std::io::Result<()> { .to(endpoints::package_versions::get_package_versions) .wrap(from_fn(auth::read_mw)), ) + .service( + web::resource("/packages/{name}/deprecate") + .put(endpoints::deprecate_version::deprecate_package_version) + .delete(endpoints::deprecate_version::deprecate_package_version) + .wrap(from_fn(auth::write_mw)), + ) .route( "/packages/{name}/{version}/{target}", web::get() .to(endpoints::package_version::get_package_version) .wrap(from_fn(auth::read_mw)), ) + .service( + web::resource("/packages/{name}/{version}/{target}/yank") + .put(endpoints::yank_version::yank_package_version) + .delete(endpoints::yank_version::yank_package_version) + .wrap(from_fn(auth::write_mw)), + ) .service( web::scope("/packages") .app_data(PayloadConfig::new(config.max_archive_size)) diff --git a/registry/src/package.rs b/registry/src/package.rs index 061fe0a..b3cf068 100644 --- a/registry/src/package.rs +++ b/registry/src/package.rs @@ -1,27 +1,34 @@ +use crate::AppState; use chrono::{DateTime, Utc}; -use pesde::manifest::target::{Target, TargetKind}; +use pesde::{ + manifest::{ + target::{Target, TargetKind}, + DependencyType, + }, + names::PackageName, + source::{ + git_index::{read_file, root_tree, GitBasedSource}, + ids::VersionId, + pesde::{IndexFile, IndexFileEntry, PesdePackageSource, ScopeInfo, SCOPE_INFO_FILE}, + specifiers::DependencySpecifiers, + }, +}; +use semver::Version; use serde::Serialize; -use std::collections::BTreeSet; +use std::collections::{BTreeMap, BTreeSet}; +use tokio::task::spawn_blocking; #[derive(Debug, Serialize, Eq, PartialEq)] -pub struct TargetInfo { - kind: TargetKind, +struct TargetInfoInner { lib: bool, bin: bool, #[serde(skip_serializing_if = "BTreeSet::is_empty")] scripts: BTreeSet, } -impl From for TargetInfo { - fn from(target: Target) -> Self { - (&target).into() - } -} - -impl From<&Target> for TargetInfo { - fn from(target: &Target) -> Self { - TargetInfo { - kind: target.kind(), +impl TargetInfoInner { + fn new(target: &Target) -> Self { + TargetInfoInner { lib: target.lib_path().is_some(), bin: target.bin_path().is_some(), scripts: target @@ -32,6 +39,25 @@ impl From<&Target> for TargetInfo { } } +#[derive(Debug, Serialize, Eq, PartialEq)] +pub struct TargetInfo { + kind: TargetKind, + #[serde(skip_serializing_if = "std::ops::Not::not")] + yanked: bool, + #[serde(flatten)] + inner: TargetInfoInner, +} + +impl TargetInfo { + fn new(target: &Target, yanked: bool) -> Self { + TargetInfo { + kind: target.kind(), + yanked, + inner: TargetInfoInner::new(target), + } + } +} + impl Ord for TargetInfo { fn cmp(&self, other: &Self) -> std::cmp::Ordering { self.kind.cmp(&other.kind) @@ -44,18 +70,199 @@ impl PartialOrd for TargetInfo { } } +#[derive(Debug, Serialize, Ord, PartialOrd, Eq, PartialEq)] +#[serde(untagged)] +pub enum RegistryDocEntryKind { + Page { + name: String, + }, + Category { + #[serde(default, skip_serializing_if = "BTreeSet::is_empty")] + items: BTreeSet, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + collapsed: bool, + }, +} + +#[derive(Debug, Serialize, Ord, PartialOrd, Eq, PartialEq)] +pub struct RegistryDocEntry { + label: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + position: Option, + #[serde(flatten)] + kind: RegistryDocEntryKind, +} + +impl From for RegistryDocEntry { + fn from(entry: pesde::source::pesde::DocEntry) -> Self { + Self { + label: entry.label, + position: entry.position, + kind: match entry.kind { + pesde::source::pesde::DocEntryKind::Page { name, .. } => { + RegistryDocEntryKind::Page { name } + } + pesde::source::pesde::DocEntryKind::Category { items, collapsed } => { + RegistryDocEntryKind::Category { + items: items.into_iter().map(Into::into).collect(), + collapsed, + } + } + }, + } + } +} + +#[derive(Debug, Serialize)] +pub struct PackageResponseInner { + published_at: DateTime, + #[serde(skip_serializing_if = "String::is_empty")] + license: String, + #[serde(skip_serializing_if = "Vec::is_empty")] + authors: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + repository: Option, + #[serde(skip_serializing_if = "BTreeSet::is_empty")] + docs: BTreeSet, + #[serde(skip_serializing_if = "BTreeMap::is_empty")] + dependencies: BTreeMap, +} + +impl PackageResponseInner { + pub fn new(entry: &IndexFileEntry) -> Self { + PackageResponseInner { + published_at: entry.published_at, + license: entry.license.clone().unwrap_or_default(), + authors: entry.authors.clone(), + repository: entry.repository.clone().map(|url| url.to_string()), + docs: entry.docs.iter().cloned().map(Into::into).collect(), + dependencies: entry.dependencies.clone(), + } + } +} + #[derive(Debug, Serialize)] pub struct PackageResponse { - pub name: String, - pub version: String, - pub targets: BTreeSet, + name: String, + version: String, + targets: BTreeSet, #[serde(skip_serializing_if = "String::is_empty")] - pub description: String, - pub published_at: DateTime, + description: String, #[serde(skip_serializing_if = "String::is_empty")] - pub license: String, - #[serde(skip_serializing_if = "Vec::is_empty")] - pub authors: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - pub repository: Option, + deprecated: String, + #[serde(flatten)] + inner: PackageResponseInner, +} + +impl PackageResponse { + pub fn new(name: &PackageName, version_id: &VersionId, file: &IndexFile) -> Self { + let entry = file.entries.get(version_id).unwrap(); + + PackageResponse { + name: name.to_string(), + version: version_id.version().to_string(), + targets: file + .entries + .iter() + .filter(|(ver, _)| ver.version() == version_id.version()) + .map(|(_, entry)| TargetInfo::new(&entry.target, entry.yanked)) + .collect(), + description: entry.description.clone().unwrap_or_default(), + deprecated: file.meta.deprecated.clone(), + inner: PackageResponseInner::new(entry), + } + } +} + +#[derive(Debug, Serialize)] +struct PackageVersionsResponseVersionInner { + target: TargetInfoInner, + #[serde(skip_serializing_if = "std::ops::Not::not")] + yanked: bool, + #[serde(flatten)] + inner: PackageResponseInner, +} + +#[derive(Debug, Serialize, Default)] +struct PackageVersionsResponseVersion { + #[serde(skip_serializing_if = "String::is_empty")] + description: String, + targets: BTreeMap, +} + +#[derive(Debug, Serialize)] +pub struct PackageVersionsResponse { + name: String, + #[serde(skip_serializing_if = "String::is_empty")] + deprecated: String, + versions: BTreeMap, +} + +impl PackageVersionsResponse { + pub fn new(name: &PackageName, file: &IndexFile) -> Self { + let mut versions = BTreeMap::::new(); + + for (v_id, entry) in file.entries.iter() { + let versions_resp = versions.entry(v_id.version().clone()).or_default(); + + versions_resp.description = entry.description.clone().unwrap_or_default(); + versions_resp.targets.insert( + entry.target.kind(), + PackageVersionsResponseVersionInner { + target: TargetInfoInner::new(&entry.target), + yanked: entry.yanked, + inner: PackageResponseInner::new(entry), + }, + ); + } + + PackageVersionsResponse { + name: name.to_string(), + deprecated: file.meta.deprecated.clone(), + versions, + } + } +} + +pub async fn read_package( + app_state: &AppState, + package: &PackageName, + source: &PesdePackageSource, +) -> Result, crate::error::RegistryError> { + let path = source.path(&app_state.project); + let package = package.clone(); + spawn_blocking(move || { + let (scope, name) = package.as_str(); + let repo = gix::open(path)?; + let tree = root_tree(&repo)?; + + let Some(versions) = read_file(&tree, [scope, name])? else { + return Ok(None); + }; + + toml::de::from_str(&versions).map_err(Into::into) + }) + .await + .unwrap() +} + +pub async fn read_scope_info( + app_state: &AppState, + scope: &str, + source: &PesdePackageSource, +) -> Result, crate::error::RegistryError> { + let path = source.path(&app_state.project); + let scope = scope.to_string(); + spawn_blocking(move || { + let repo = gix::open(path)?; + let tree = root_tree(&repo)?; + + let Some(versions) = read_file(&tree, [&*scope, SCOPE_INFO_FILE])? else { + return Ok(None); + }; + + toml::de::from_str(&versions).map_err(Into::into) + }) + .await + .unwrap() } diff --git a/registry/src/request_path.rs b/registry/src/request_path.rs new file mode 100644 index 0000000..d49abf4 --- /dev/null +++ b/registry/src/request_path.rs @@ -0,0 +1,69 @@ +use pesde::manifest::target::TargetKind; +use semver::Version; +use serde::{Deserialize, Deserializer}; + +#[derive(Debug)] +pub enum LatestOrSpecificVersion { + Latest, + Specific(Version), +} + +impl<'de> Deserialize<'de> for LatestOrSpecificVersion { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + if s.eq_ignore_ascii_case("latest") { + return Ok(LatestOrSpecificVersion::Latest); + } + + s.parse() + .map(LatestOrSpecificVersion::Specific) + .map_err(serde::de::Error::custom) + } +} + +#[derive(Debug)] +pub enum AnyOrSpecificTarget { + Any, + Specific(TargetKind), +} + +impl<'de> Deserialize<'de> for AnyOrSpecificTarget { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + if s.eq_ignore_ascii_case("any") { + return Ok(AnyOrSpecificTarget::Any); + } + + s.parse() + .map(AnyOrSpecificTarget::Specific) + .map_err(serde::de::Error::custom) + } +} + +#[derive(Debug)] +pub enum AllOrSpecificTarget { + All, + Specific(TargetKind), +} + +impl<'de> Deserialize<'de> for AllOrSpecificTarget { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + if s.eq_ignore_ascii_case("all") { + return Ok(AllOrSpecificTarget::All); + } + + s.parse() + .map(AllOrSpecificTarget::Specific) + .map_err(serde::de::Error::custom) + } +} diff --git a/registry/src/search.rs b/registry/src/search.rs index 73ac739..a4df6b2 100644 --- a/registry/src/search.rs +++ b/registry/src/search.rs @@ -5,10 +5,12 @@ use pesde::{ names::PackageName, source::{ git_index::{root_tree, GitBasedSource}, + ids::VersionId, pesde::{IndexFile, IndexFileEntry, PesdePackageSource, SCOPE_INFO_FILE}, }, Project, }; +use semver::Version; use tantivy::{ doc, query::QueryParser, @@ -18,7 +20,7 @@ use tantivy::{ }; use tokio::pin; -pub async fn all_packages( +async fn all_packages( source: &PesdePackageSource, project: &Project, ) -> impl Stream { @@ -67,6 +69,18 @@ pub async fn all_packages( } } +fn find_max(file: &IndexFile) -> Option<(&VersionId, &IndexFileEntry)> { + file.entries + .iter() + .filter(|(_, entry)| !entry.yanked) + .max_by(|(v_id_a, entry_a), (v_id_b, entry_b)| { + v_id_a + .version() + .cmp(v_id_b.version()) + .then(entry_a.published_at.cmp(&entry_b.published_at)) + }) +} + pub async fn make_search( project: &Project, source: &PesdePackageSource, @@ -80,6 +94,8 @@ pub async fn make_search( ); let id_field = schema_builder.add_text_field("id", STRING | STORED); + let version = schema_builder.add_text_field("version", STRING | STORED); + let scope = schema_builder.add_text_field("scope", field_options.clone()); let name = schema_builder.add_text_field("name", field_options.clone()); let description = schema_builder.add_text_field("description", field_options); @@ -103,18 +119,22 @@ pub async fn make_search( let stream = all_packages(source, project).await; pin!(stream); - while let Some((pkg_name, mut file)) = stream.next().await { - let Some((_, latest_entry)) = file.entries.pop_last() else { - tracing::error!("no versions found for {pkg_name}"); + while let Some((pkg_name, file)) = stream.next().await { + if !file.meta.deprecated.is_empty() { + continue; + } + + let Some((v_id, latest_entry)) = find_max(&file) else { continue; }; search_writer .add_document(doc!( id_field => pkg_name.to_string(), + version => v_id.version().to_string(), scope => pkg_name.as_str().0, name => pkg_name.as_str().1, - description => latest_entry.description.unwrap_or_default(), + description => latest_entry.description.clone().unwrap_or_default(), published_at => DateTime::from_timestamp_secs(latest_entry.published_at.timestamp()), )) .unwrap(); @@ -130,7 +150,12 @@ pub async fn make_search( (search_reader, search_writer, query_parser) } -pub fn update_version(app_state: &AppState, name: &PackageName, entry: IndexFileEntry) { +pub fn update_search_version( + app_state: &AppState, + name: &PackageName, + version: &Version, + entry: &IndexFileEntry, +) { let mut search_writer = app_state.search_writer.lock().unwrap(); let schema = search_writer.index().schema(); let id_field = schema.get_field("id").unwrap(); @@ -139,12 +164,35 @@ pub fn update_version(app_state: &AppState, name: &PackageName, entry: IndexFile search_writer.add_document(doc!( id_field => name.to_string(), + schema.get_field("version").unwrap() => version.to_string(), schema.get_field("scope").unwrap() => name.as_str().0, schema.get_field("name").unwrap() => name.as_str().1, - schema.get_field("description").unwrap() => entry.description.unwrap_or_default(), + schema.get_field("description").unwrap() => entry.description.clone().unwrap_or_default(), schema.get_field("published_at").unwrap() => DateTime::from_timestamp_secs(entry.published_at.timestamp()) )).unwrap(); search_writer.commit().unwrap(); app_state.search_reader.reload().unwrap(); } + +pub fn search_version_changed(app_state: &AppState, name: &PackageName, file: &IndexFile) { + let entry = if file.meta.deprecated.is_empty() { + find_max(file) + } else { + None + }; + + let Some((v_id, entry)) = entry else { + let mut search_writer = app_state.search_writer.lock().unwrap(); + let schema = search_writer.index().schema(); + let id_field = schema.get_field("id").unwrap(); + + search_writer.delete_term(Term::from_field_text(id_field, &name.to_string())); + search_writer.commit().unwrap(); + app_state.search_reader.reload().unwrap(); + + return; + }; + + update_search_version(app_state, name, v_id.version(), entry); +} diff --git a/registry/src/storage/fs.rs b/registry/src/storage/fs.rs index 0de6b58..492ed52 100644 --- a/registry/src/storage/fs.rs +++ b/registry/src/storage/fs.rs @@ -1,4 +1,4 @@ -use crate::{error::Error, storage::StorageImpl}; +use crate::{error::RegistryError, storage::StorageImpl}; use actix_web::{ http::header::{CONTENT_ENCODING, CONTENT_TYPE}, HttpResponse, @@ -15,7 +15,10 @@ pub struct FSStorage { pub root: PathBuf, } -async fn read_file_to_response(path: &Path, content_type: &str) -> Result { +async fn read_file_to_response( + path: &Path, + content_type: &str, +) -> Result { Ok(match fs::read(path).await { Ok(contents) => HttpResponse::Ok() .append_header((CONTENT_TYPE, content_type)) @@ -32,7 +35,7 @@ impl StorageImpl for FSStorage { package_name: &PackageName, version: &VersionId, contents: Vec, - ) -> Result<(), Error> { + ) -> Result<(), RegistryError> { let (scope, name) = package_name.as_str(); let path = self @@ -52,7 +55,7 @@ impl StorageImpl for FSStorage { &self, package_name: &PackageName, version: &VersionId, - ) -> Result { + ) -> Result { let (scope, name) = package_name.as_str(); let path = self @@ -70,7 +73,7 @@ impl StorageImpl for FSStorage { package_name: &PackageName, version: &VersionId, contents: Vec, - ) -> Result<(), Error> { + ) -> Result<(), RegistryError> { let (scope, name) = package_name.as_str(); let path = self @@ -90,7 +93,7 @@ impl StorageImpl for FSStorage { &self, package_name: &PackageName, version: &VersionId, - ) -> Result { + ) -> Result { let (scope, name) = package_name.as_str(); let path = self @@ -103,7 +106,7 @@ impl StorageImpl for FSStorage { read_file_to_response(&path.join("readme.gz"), "text/plain").await } - async fn store_doc(&self, doc_hash: String, contents: Vec) -> Result<(), Error> { + async fn store_doc(&self, doc_hash: String, contents: Vec) -> Result<(), RegistryError> { let path = self.root.join("Doc"); fs::create_dir_all(&path).await?; @@ -112,7 +115,7 @@ impl StorageImpl for FSStorage { Ok(()) } - async fn get_doc(&self, doc_hash: &str) -> Result { + async fn get_doc(&self, doc_hash: &str) -> Result { let path = self.root.join("Doc"); read_file_to_response(&path.join(format!("{doc_hash}.gz")), "text/plain").await diff --git a/registry/src/storage/mod.rs b/registry/src/storage/mod.rs index 6202044..f6a13c6 100644 --- a/registry/src/storage/mod.rs +++ b/registry/src/storage/mod.rs @@ -1,4 +1,4 @@ -use crate::{benv, error::Error, make_reqwest}; +use crate::{benv, error::RegistryError, make_reqwest}; use actix_web::HttpResponse; use pesde::{names::PackageName, source::ids::VersionId}; use rusty_s3::{Bucket, Credentials, UrlStyle}; @@ -19,31 +19,31 @@ pub trait StorageImpl: Display { package_name: &PackageName, version: &VersionId, contents: Vec, - ) -> Result<(), crate::error::Error>; + ) -> Result<(), crate::error::RegistryError>; async fn get_package( &self, package_name: &PackageName, version: &VersionId, - ) -> Result; + ) -> Result; async fn store_readme( &self, package_name: &PackageName, version: &VersionId, contents: Vec, - ) -> Result<(), crate::error::Error>; + ) -> Result<(), crate::error::RegistryError>; async fn get_readme( &self, package_name: &PackageName, version: &VersionId, - ) -> Result; + ) -> Result; async fn store_doc( &self, doc_hash: String, contents: Vec, - ) -> Result<(), crate::error::Error>; - async fn get_doc(&self, doc_hash: &str) -> Result; + ) -> Result<(), crate::error::RegistryError>; + async fn get_doc(&self, doc_hash: &str) -> Result; } impl StorageImpl for Storage { @@ -52,7 +52,7 @@ impl StorageImpl for Storage { package_name: &PackageName, version: &VersionId, contents: Vec, - ) -> Result<(), Error> { + ) -> Result<(), RegistryError> { match self { Storage::S3(s3) => s3.store_package(package_name, version, contents).await, Storage::FS(fs) => fs.store_package(package_name, version, contents).await, @@ -63,7 +63,7 @@ impl StorageImpl for Storage { &self, package_name: &PackageName, version: &VersionId, - ) -> Result { + ) -> Result { match self { Storage::S3(s3) => s3.get_package(package_name, version).await, Storage::FS(fs) => fs.get_package(package_name, version).await, @@ -75,7 +75,7 @@ impl StorageImpl for Storage { package_name: &PackageName, version: &VersionId, contents: Vec, - ) -> Result<(), Error> { + ) -> Result<(), RegistryError> { match self { Storage::S3(s3) => s3.store_readme(package_name, version, contents).await, Storage::FS(fs) => fs.store_readme(package_name, version, contents).await, @@ -86,21 +86,21 @@ impl StorageImpl for Storage { &self, package_name: &PackageName, version: &VersionId, - ) -> Result { + ) -> Result { match self { Storage::S3(s3) => s3.get_readme(package_name, version).await, Storage::FS(fs) => fs.get_readme(package_name, version).await, } } - async fn store_doc(&self, doc_hash: String, contents: Vec) -> Result<(), Error> { + async fn store_doc(&self, doc_hash: String, contents: Vec) -> Result<(), RegistryError> { match self { Storage::S3(s3) => s3.store_doc(doc_hash, contents).await, Storage::FS(fs) => fs.store_doc(doc_hash, contents).await, } } - async fn get_doc(&self, doc_hash: &str) -> Result { + async fn get_doc(&self, doc_hash: &str) -> Result { match self { Storage::S3(s3) => s3.get_doc(doc_hash).await, Storage::FS(fs) => fs.get_doc(doc_hash).await, diff --git a/registry/src/storage/s3.rs b/registry/src/storage/s3.rs index c60086c..35dd91e 100644 --- a/registry/src/storage/s3.rs +++ b/registry/src/storage/s3.rs @@ -1,5 +1,5 @@ use crate::{ - error::{Error, ReqwestErrorExt}, + error::{RegistryError, ReqwestErrorExt}, storage::StorageImpl, }; use actix_web::{http::header::LOCATION, HttpResponse}; @@ -26,7 +26,7 @@ impl StorageImpl for S3Storage { package_name: &PackageName, version: &VersionId, contents: Vec, - ) -> Result<(), Error> { + ) -> Result<(), RegistryError> { let object_url = PutObject::new( &self.s3_bucket, Some(&self.s3_credentials), @@ -55,7 +55,7 @@ impl StorageImpl for S3Storage { &self, package_name: &PackageName, version: &VersionId, - ) -> Result { + ) -> Result { let object_url = GetObject::new( &self.s3_bucket, Some(&self.s3_credentials), @@ -77,7 +77,7 @@ impl StorageImpl for S3Storage { package_name: &PackageName, version: &VersionId, contents: Vec, - ) -> Result<(), Error> { + ) -> Result<(), RegistryError> { let object_url = PutObject::new( &self.s3_bucket, Some(&self.s3_credentials), @@ -106,7 +106,7 @@ impl StorageImpl for S3Storage { &self, package_name: &PackageName, version: &VersionId, - ) -> Result { + ) -> Result { let object_url = GetObject::new( &self.s3_bucket, Some(&self.s3_credentials), @@ -123,7 +123,7 @@ impl StorageImpl for S3Storage { .finish()) } - async fn store_doc(&self, doc_hash: String, contents: Vec) -> Result<(), Error> { + async fn store_doc(&self, doc_hash: String, contents: Vec) -> Result<(), RegistryError> { let object_url = PutObject::new( &self.s3_bucket, Some(&self.s3_credentials), @@ -145,7 +145,7 @@ impl StorageImpl for S3Storage { Ok(()) } - async fn get_doc(&self, doc_hash: &str) -> Result { + async fn get_doc(&self, doc_hash: &str) -> Result { let object_url = GetObject::new( &self.s3_bucket, Some(&self.s3_credentials), diff --git a/src/cli/commands/auth/mod.rs b/src/cli/commands/auth/mod.rs index 6193f9e..d718543 100644 --- a/src/cli/commands/auth/mod.rs +++ b/src/cli/commands/auth/mod.rs @@ -1,7 +1,6 @@ -use crate::cli::config::read_config; -use anyhow::Context; +use crate::cli::get_index; use clap::{Args, Subcommand}; -use pesde::{errors::ManifestReadError, Project, DEFAULT_INDEX_NAME}; +use pesde::Project; mod login; mod logout; @@ -33,37 +32,7 @@ pub enum AuthCommands { impl AuthSubcommand { pub async fn run(self, project: Project, reqwest: reqwest::Client) -> anyhow::Result<()> { - let manifest = match project.deser_manifest().await { - Ok(manifest) => Some(manifest), - Err(e) => match e { - ManifestReadError::Io(e) if e.kind() == std::io::ErrorKind::NotFound => None, - e => return Err(e.into()), - }, - }; - - let index_url = match self.index.as_deref() { - Some(index) => match index.try_into() { - Ok(url) => Some(url), - Err(_) => None, - }, - None => match manifest { - Some(_) => None, - None => Some(read_config().await?.default_index), - }, - }; - - let index_url = match index_url { - Some(url) => url, - None => { - let index_name = self.index.as_deref().unwrap_or(DEFAULT_INDEX_NAME); - - manifest - .unwrap() - .indices - .remove(index_name) - .with_context(|| format!("index {index_name} not found in manifest"))? - } - }; + let index_url = get_index(&project, self.index.as_deref()).await?; match self.command { AuthCommands::Login(login) => login.run(index_url, project, reqwest).await, diff --git a/src/cli/commands/deprecate.rs b/src/cli/commands/deprecate.rs new file mode 100644 index 0000000..08212fa --- /dev/null +++ b/src/cli/commands/deprecate.rs @@ -0,0 +1,111 @@ +use crate::cli::get_index; +use anyhow::Context; +use clap::Args; +use colored::Colorize; +use pesde::{ + names::PackageName, + source::{ + pesde::PesdePackageSource, + traits::{PackageSource, RefreshOptions}, + }, + Project, +}; +use reqwest::{header::AUTHORIZATION, Method, StatusCode}; + +#[derive(Debug, Args)] +pub struct DeprecateCommand { + /// Whether to undeprecate the package + #[clap(long)] + undo: bool, + + /// The index to deprecate the package in + #[clap(short, long)] + index: Option, + + /// The package to deprecate + #[clap(index = 1)] + package: PackageName, + + /// The reason for deprecating the package + #[clap(index = 2, required_unless_present = "undo")] + reason: Option, +} + +impl DeprecateCommand { + pub async fn run(self, project: Project, reqwest: reqwest::Client) -> anyhow::Result<()> { + let index_url = get_index(&project, self.index.as_deref()).await?; + let source = PesdePackageSource::new(index_url.clone()); + source + .refresh(&RefreshOptions { + project: project.clone(), + }) + .await + .context("failed to refresh source")?; + let config = source + .config(&project) + .await + .context("failed to get index config")?; + + let mut request = reqwest.request( + if self.undo { + Method::DELETE + } else { + Method::PUT + }, + format!( + "{}/v0/packages/{}/deprecate", + config.api(), + urlencoding::encode(&self.package.to_string()), + ), + ); + + if !self.undo { + request = request.body( + self.reason + .map(|reason| reason.trim().to_string()) + .filter(|reason| !reason.is_empty()) + .context("deprecating must have non-empty a reason")?, + ); + } + + if let Some(token) = project.auth_config().tokens().get(&index_url) { + tracing::debug!("using token for {index_url}"); + request = request.header(AUTHORIZATION, token); + } + + let response = request.send().await.context("failed to send request")?; + + let status = response.status(); + let text = response + .text() + .await + .context("failed to get response text")?; + let prefix = if self.undo { "un" } else { "" }; + match status { + StatusCode::CONFLICT => { + println!( + "{}", + format!("version is already {prefix}deprecated") + .red() + .bold() + ); + } + StatusCode::FORBIDDEN => { + println!( + "{}", + format!("unauthorized to {prefix}deprecate under this scope") + .red() + .bold() + ); + } + code if !code.is_success() => { + anyhow::bail!("failed to {prefix}deprecate package: {code} ({text})"); + } + _ => { + println!("{text}"); + } + } + + Ok(()) + } +} diff --git a/src/cli/commands/mod.rs b/src/cli/commands/mod.rs index 764917c..04229ed 100644 --- a/src/cli/commands/mod.rs +++ b/src/cli/commands/mod.rs @@ -3,6 +3,7 @@ use pesde::Project; mod add; mod auth; mod config; +mod deprecate; mod execute; mod init; mod install; @@ -18,6 +19,7 @@ mod self_install; #[cfg(feature = "version-management")] mod self_upgrade; mod update; +mod yank; #[derive(Debug, clap::Subcommand)] pub enum Subcommand { @@ -68,6 +70,12 @@ pub enum Subcommand { /// Executes a binary package without needing to be run in a project directory #[clap(name = "x", visible_alias = "execute", visible_alias = "exec")] Execute(execute::ExecuteCommand), + + /// Yanks a package from the registry + Yank(yank::YankCommand), + + /// Deprecates a package from the registry + Deprecate(deprecate::DeprecateCommand), } impl Subcommand { @@ -91,6 +99,8 @@ impl Subcommand { Subcommand::Update(update) => update.run(project, reqwest).await, Subcommand::Outdated(outdated) => outdated.run(project).await, Subcommand::Execute(execute) => execute.run(project, reqwest).await, + Subcommand::Yank(yank) => yank.run(project, reqwest).await, + Subcommand::Deprecate(deprecate) => deprecate.run(project, reqwest).await, } } } diff --git a/src/cli/commands/yank.rs b/src/cli/commands/yank.rs new file mode 100644 index 0000000..dd2ffae --- /dev/null +++ b/src/cli/commands/yank.rs @@ -0,0 +1,157 @@ +use crate::cli::get_index; +use anyhow::Context; +use clap::Args; +use colored::Colorize; +use pesde::{ + manifest::target::TargetKind, + names::PackageName, + source::{ + pesde::PesdePackageSource, + traits::{PackageSource, RefreshOptions}, + }, + Project, +}; +use reqwest::{header::AUTHORIZATION, Method, StatusCode}; +use semver::Version; +use std::{fmt::Display, str::FromStr}; + +#[derive(Debug, Clone)] +enum TargetKindOrAll { + All, + Specific(TargetKind), +} + +impl Display for TargetKindOrAll { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + TargetKindOrAll::All => write!(f, "all"), + TargetKindOrAll::Specific(kind) => write!(f, "{kind}"), + } + } +} + +impl FromStr for TargetKindOrAll { + type Err = anyhow::Error; + + fn from_str(s: &str) -> Result { + if s.eq_ignore_ascii_case("all") { + return Ok(TargetKindOrAll::All); + } + + s.parse() + .map(TargetKindOrAll::Specific) + .context("failed to parse target kind") + } +} + +#[derive(Debug, Clone)] +struct YankId(PackageName, Version, TargetKindOrAll); + +impl FromStr for YankId { + type Err = anyhow::Error; + + fn from_str(s: &str) -> Result { + let (package, version) = s + .split_once('@') + .context("package is not in format of `scope/name@version target`")?; + let target = match version.split(' ').nth(1) { + Some(target) => target + .parse() + .context("package is not in format of `scope/name@version target`")?, + None => TargetKindOrAll::All, + }; + + Ok(YankId( + package.parse().context("failed to parse package name")?, + version.parse().context("failed to parse version")?, + target, + )) + } +} + +#[derive(Debug, Args)] +pub struct YankCommand { + /// Whether to unyank the package + #[clap(long)] + undo: bool, + + /// The index to yank the package from + #[clap(short, long)] + index: Option, + + /// The package to yank + #[clap(index = 1)] + package: YankId, +} + +impl YankCommand { + pub async fn run(self, project: Project, reqwest: reqwest::Client) -> anyhow::Result<()> { + let YankId(package, version, target) = self.package; + + let index_url = get_index(&project, self.index.as_deref()).await?; + let source = PesdePackageSource::new(index_url.clone()); + source + .refresh(&RefreshOptions { + project: project.clone(), + }) + .await + .context("failed to refresh source")?; + let config = source + .config(&project) + .await + .context("failed to get index config")?; + + let mut request = reqwest.request( + if self.undo { + Method::DELETE + } else { + Method::PUT + }, + format!( + "{}/v0/packages/{}/{}/{}/yank", + config.api(), + urlencoding::encode(&package.to_string()), + urlencoding::encode(&version.to_string()), + urlencoding::encode(&target.to_string()), + ), + ); + + if let Some(token) = project.auth_config().tokens().get(&index_url) { + tracing::debug!("using token for {index_url}"); + request = request.header(AUTHORIZATION, token); + } + + let response = request.send().await.context("failed to send request")?; + + let status = response.status(); + let text = response + .text() + .await + .context("failed to get response text")?; + let prefix = if self.undo { "un" } else { "" }; + match status { + StatusCode::CONFLICT => { + println!( + "{}", + format!("version is already {prefix}yanked").red().bold() + ); + } + StatusCode::FORBIDDEN => { + println!( + "{}", + format!("unauthorized to {prefix}yank under this scope") + .red() + .bold() + ); + } + code if !code.is_success() => { + anyhow::bail!("failed to {prefix}yank package: {code} ({text})"); + } + _ => { + println!("{text}"); + } + } + + Ok(()) + } +} diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 2296117..09fa730 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -1,8 +1,10 @@ +use crate::cli::config::read_config; use anyhow::Context; use colored::Colorize; use fs_err::tokio as fs; use futures::StreamExt; use pesde::{ + errors::ManifestReadError, lockfile::Lockfile, manifest::{ overrides::{OverrideKey, OverrideSpecifier}, @@ -13,7 +15,7 @@ use pesde::{ source::{ ids::VersionId, specifiers::DependencySpecifiers, workspace::specifier::VersionTypeOrReq, }, - Project, + Project, DEFAULT_INDEX_NAME, }; use relative_path::RelativePathBuf; use std::{ @@ -310,3 +312,37 @@ pub fn display_err(result: anyhow::Result<()>, prefix: &str) { } } } + +pub async fn get_index(project: &Project, index: Option<&str>) -> anyhow::Result { + let manifest = match project.deser_manifest().await { + Ok(manifest) => Some(manifest), + Err(e) => match e { + ManifestReadError::Io(e) if e.kind() == std::io::ErrorKind::NotFound => None, + e => return Err(e.into()), + }, + }; + + let index_url = match index { + Some(index) => match index.try_into() { + Ok(url) => Some(url), + Err(_) => None, + }, + None => match manifest { + Some(_) => None, + None => Some(read_config().await?.default_index), + }, + }; + + match index_url { + Some(url) => Ok(url), + None => { + let index_name = index.unwrap_or(DEFAULT_INDEX_NAME); + + manifest + .unwrap() + .indices + .remove(index_name) + .with_context(|| format!("index {index_name} not found in manifest")) + } + } +} diff --git a/src/names.rs b/src/names.rs index 47de7d5..f1ddf0b 100644 --- a/src/names.rs +++ b/src/names.rs @@ -97,6 +97,16 @@ impl PackageName { pub fn escaped(&self) -> String { format!("{}+{}", self.0, self.1) } + + /// Returns the scope of the package name + pub fn scope(&self) -> &str { + &self.0 + } + + /// Returns the name of the package name + pub fn name(&self) -> &str { + &self.1 + } } /// All possible package names diff --git a/src/source/pesde/mod.rs b/src/source/pesde/mod.rs index ca0bff5..a1c41bf 100644 --- a/src/source/pesde/mod.rs +++ b/src/source/pesde/mod.rs @@ -140,17 +140,23 @@ impl PackageSource for PesdePackageSource { .. } = options; - let Some(IndexFile { entries, .. }) = self.read_index_file(&specifier.name, project)? + let Some(IndexFile { meta, entries, .. }) = + self.read_index_file(&specifier.name, project)? else { return Err(errors::ResolveError::NotFound(specifier.name.to_string())); }; + if !meta.deprecated.is_empty() { + tracing::warn!("{} is deprecated: {}", specifier.name, meta.deprecated); + } + tracing::debug!("{} has {} possible entries", specifier.name, entries.len()); Ok(( PackageNames::Pesde(specifier.name.clone()), entries .into_iter() + .filter(|(_, entry)| !entry.yanked) .filter(|(VersionId(version, target), _)| { specifier.version.matches(version) && specifier.target.unwrap_or(*project_target) == *target @@ -484,6 +490,10 @@ pub struct IndexFileEntry { #[serde(default, skip_serializing_if = "BTreeSet::is_empty")] pub docs: BTreeSet, + /// Whether this version is yanked + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub yanked: bool, + /// The dependencies of this package #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub dependencies: BTreeMap, @@ -491,10 +501,14 @@ pub struct IndexFileEntry { /// The package metadata in the index file #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default)] -pub struct IndexMetadata {} +pub struct IndexMetadata { + /// Whether this package is deprecated + #[serde(default, skip_serializing_if = "String::is_empty")] + pub deprecated: String, +} /// The index file for a package -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Default)] pub struct IndexFile { /// Any package-wide metadata #[serde(default, skip_serializing_if = "crate::util::is_default")] diff --git a/website/src/lib/registry-api.ts b/website/src/lib/registry-api.ts index 743870d..abb92a6 100644 --- a/website/src/lib/registry-api.ts +++ b/website/src/lib/registry-api.ts @@ -5,32 +5,57 @@ export type SearchResponse = { data: PackageResponse[] } -export type PackageVersionsResponse = PackageResponse[] +export type PackageVersionsResponse = { + name: string + deprecated?: string + versions: Record< + string, + { + description?: string + targets: Record< + TargetKind, + { target: TargetInfoInner; yanked?: boolean } & PackageResponseInner + > + } + > +} export type PackageVersionResponse = PackageResponse +export type PackageResponseInner = { + published_at: string + license?: string + authors?: string[] + repository?: string + docs?: DocEntry[] + dependencies?: Record +} + export type PackageResponse = { name: string version: string targets: TargetInfo[] - description: string - published_at: string - license?: string - authors?: string[] - repository?: string - dependencies: Record - docs?: DocEntry[] -} + description?: string + deprecated?: string +} & PackageResponseInner -export type TargetInfo = { - kind: TargetKind +export type TargetInfoInner = { lib: boolean bin: boolean scripts?: string[] } +export type TargetInfo = { + yanked?: boolean + kind: TargetKind +} & TargetInfoInner + export type TargetKind = "roblox" | "roblox_server" | "lune" | "luau" +export const isTargetKind = (value: string | undefined): value is TargetKind => { + return value === "roblox" || value === "roblox_server" || value === "lune" || value === "luau" +} + export type DependencyEntry = [DependencyInfo, DependencyKind] export type DependencyInfo = @@ -62,7 +87,6 @@ export type DocEntryCategory = DocEntryBase & { export type DocEntryPage = DocEntryBase & { name: string - hash: string } export const TARGET_KIND_DISPLAY_NAMES: Record = { diff --git a/website/src/routes/(app)/packages/[scope]/[name]/+layout.svelte b/website/src/routes/(app)/packages/[scope]/[name]/+layout.svelte index 2b01a6e..21b84da 100644 --- a/website/src/routes/(app)/packages/[scope]/[name]/+layout.svelte +++ b/website/src/routes/(app)/packages/[scope]/[name]/+layout.svelte @@ -73,6 +73,15 @@

{pkgDescription}

+ {#if data.pkg.deprecated} +
+

+ + Deprecated +

+

{data.pkg.deprecated}

+
+ {/if}
diff --git a/website/src/routes/(app)/packages/[scope]/[name]/TargetSelector.svelte b/website/src/routes/(app)/packages/[scope]/[name]/TargetSelector.svelte index d89242a..7b52df6 100644 --- a/website/src/routes/(app)/packages/[scope]/[name]/TargetSelector.svelte +++ b/website/src/routes/(app)/packages/[scope]/[name]/TargetSelector.svelte @@ -5,6 +5,7 @@ import { TARGET_KIND_DISPLAY_NAMES, type TargetInfo, type TargetKind } from "$lib/registry-api" import { Label, useId } from "bits-ui" import { getContext } from "svelte" + import { TriangleAlert } from "lucide-svelte" const currentTarget = getContext<{ value: TargetInfo }>("currentTarget") @@ -32,6 +33,14 @@
(open = true)}>Target + {#if currentTarget.value.yanked} + + + Yanked + + {/if}
({ - value: target.kind, - label: TARGET_KIND_DISPLAY_NAMES[target.kind], + items={Object.keys($page.data.pkg.targets).map((target) => ({ + value: target, + label: TARGET_KIND_DISPLAY_NAMES[target as TargetKind], }))} value={$page.params.target ?? $page.data.pkg.targets[0].kind} contentClass={sameWidth ? "" : "w-32"} diff --git a/website/src/routes/(app)/packages/[scope]/[name]/[[version]]/[[target]]/dependencies/+page.svelte b/website/src/routes/(app)/packages/[scope]/[name]/[[version]]/[[target]]/dependencies/+page.svelte index d5df99c..8ed7782 100644 --- a/website/src/routes/(app)/packages/[scope]/[name]/[[version]]/[[target]]/dependencies/+page.svelte +++ b/website/src/routes/(app)/packages/[scope]/[name]/[[version]]/[[target]]/dependencies/+page.svelte @@ -4,26 +4,12 @@ const { data } = $props() - // Vercel only supports up to Node 20.x, which doesn't support Object.groupBy - function groupBy( - arr: T[], - predicate: (value: T) => K, - ): Partial> { - const groups: Partial> = {} - for (const item of arr) { - const key = predicate(item) - if (key in groups) { - groups[key]!.push(item) - } else { - groups[key] = [item] - } - } - return groups - } - let groupedDeps = $derived( - groupBy( - Object.entries(data.pkg.dependencies).map(([alias, dependency]) => ({ alias, dependency })), + Object.groupBy( + Object.entries(data.pkg.dependencies ?? {}).map(([alias, dependency]) => ({ + alias, + dependency, + })), (entry) => entry.dependency[1], ), ) diff --git a/website/src/routes/(app)/packages/[scope]/[name]/versions/+page.svelte b/website/src/routes/(app)/packages/[scope]/[name]/versions/+page.svelte index 123509c..83c9a65 100644 --- a/website/src/routes/(app)/packages/[scope]/[name]/versions/+page.svelte +++ b/website/src/routes/(app)/packages/[scope]/[name]/versions/+page.svelte @@ -1,5 +1,5 @@
- {#each data.versions as pkg, index} + {#each data.versions as pkgVersion, index} {@const isLatest = index === 0}
yanked)} >

- {pkg.version} + {pkgVersion.version} {#if isLatest} (latest) {/if}

-
{/each} diff --git a/website/src/routes/(app)/packages/[scope]/[name]/versions/+page.ts b/website/src/routes/(app)/packages/[scope]/[name]/versions/+page.ts index 26b58e0..da7dc3d 100644 --- a/website/src/routes/(app)/packages/[scope]/[name]/versions/+page.ts +++ b/website/src/routes/(app)/packages/[scope]/[name]/versions/+page.ts @@ -10,18 +10,30 @@ export const load: PageLoad = async ({ params, fetch }) => { const { scope, name } = params try { - const versions = await fetchRegistryJson( + const versionsResponse = await fetchRegistryJson( `packages/${encodeURIComponent(`${scope}/${name}`)}`, fetch, ) - versions.reverse() + const versions = Object.entries(versionsResponse.versions) + .map(([version, data]) => ({ + version, + description: data.description, + targets: data.targets, + published_at: Object.values(data.targets) + .map(({ published_at }) => new Date(published_at)) + .sort() + .reverse()[0] + .toISOString(), + })) + .reverse() return { + name: versionsResponse.name, versions, meta: { - title: `${versions[0].name} - versions`, + title: `${versionsResponse.name} - versions`, description: versions[0].description, }, }