diff --git a/Cargo.lock b/Cargo.lock index bf202d10..ca207a10 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2287,6 +2287,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "minicov" version = "0.3.8" @@ -2933,6 +2943,7 @@ dependencies = [ "js-sys", "log", "mime", + "mime_guess", "native-tls", "percent-encoding", "pin-project-lite", diff --git a/Cargo.toml b/Cargo.toml index c597f919..594e12c2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ members = [ "bindings/js", "bindings/java", ] +default-members = ["sysand", "core"] [workspace.package] version = "0.0.9" diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..d2eb6b44 --- /dev/null +++ b/Makefile @@ -0,0 +1,178 @@ +# Sysand Local Testing Makefile + +# Variables +WORKSPACE := $(shell pwd) +SYSAND := cargo run --manifest-path $(WORKSPACE)/Cargo.toml -p sysand -- +TEST_ROOT := /tmp/sysand-test +TEST1_DIR := $(TEST_ROOT)/test1 +TEST2_DIR := $(TEST_ROOT)/test2 +TEST_USE_ROOT := /tmp/sysand-test-use +TEST_USE_DIR := $(TEST_USE_ROOT)/consumer +INDEX_URL := https://sysand-index-alpha.syside.app +INDEX_READ_URL := https://sysand-index-alpha.syside.app/index/ + +# Default target +.PHONY: help +help: + @echo "Sysand Testing Makefile" + @echo "" + @echo "Available targets:" + @echo " setup - Initialize test projects (test1 and test2)" + @echo " publish - Publish a project (requires auth, use PROJECT=test1 or test2)" + @echo " test-index-use - Test resolving dependencies from the index (public reads)" + @echo " info - Show project information" + @echo " clean - Remove all test directories" + @echo "" + @echo "Authentication (required for publishing):" + @echo " export SYSAND_CRED_ALPHA='$(INDEX_URL)/**'" + @echo " export SYSAND_CRED_ALPHA_BEARER_TOKEN='sysand_u_your_token_here'" + @echo "" + @echo "Typical workflow:" + @echo " 1. make setup # Create test projects" + @echo " 2. make publish PROJECT=test1 # Publish test1" + @echo " 3. make publish PROJECT=test2 # Publish test2" + @echo " 4. make test-index-use # Test fetching from index" + +# Setup: Create test projects +.PHONY: setup +setup: clean + @echo "==> Setting up test projects in $(TEST_ROOT)" + @mkdir -p $(TEST_ROOT) + + @echo "" + @echo "==> Creating test1 project..." + @mkdir -p $(TEST1_DIR) + @cd $(TEST1_DIR) && $(SYSAND) init \ + --name test1 \ + --version 0.1.0 + + @echo "package Test1Package {" > $(TEST1_DIR)/model.sysml + @echo " part def ExamplePart {" >> $(TEST1_DIR)/model.sysml + @echo " doc /* A simple example part from test1 */" >> $(TEST1_DIR)/model.sysml + @echo " }" >> $(TEST1_DIR)/model.sysml + @echo "}" >> $(TEST1_DIR)/model.sysml + + @cd $(TEST1_DIR) && $(SYSAND) include model.sysml + @cd $(TEST1_DIR) && $(SYSAND) build + @echo "✓ test1 built successfully" + + @echo "" + @echo "==> Creating test2 project..." + @mkdir -p $(TEST2_DIR) + @cd $(TEST2_DIR) && $(SYSAND) init \ + --name test2 \ + --version 0.1.0 + + @cd $(TEST2_DIR) && $(SYSAND) add "pkg:sysand/test1" "0.1.0" --no-lock --no-sync + + @echo "package Test2Package {" > $(TEST2_DIR)/model.sysml + @echo " import Test1Package::*;" >> $(TEST2_DIR)/model.sysml + @echo " " >> $(TEST2_DIR)/model.sysml + @echo " part def MyPart {" >> $(TEST2_DIR)/model.sysml + @echo " part example : ExamplePart;" >> $(TEST2_DIR)/model.sysml + @echo " doc /* Uses ExamplePart from test1 */" >> $(TEST2_DIR)/model.sysml + @echo " }" >> $(TEST2_DIR)/model.sysml + @echo "}" >> $(TEST2_DIR)/model.sysml + + @cd $(TEST2_DIR) && $(SYSAND) include model.sysml + @cd $(TEST2_DIR) && $(SYSAND) build + @echo "✓ test2 built successfully" + + @echo "" + @echo "==> Setup complete!" + @echo " test1: $(TEST1_DIR)" + @echo " test2: $(TEST2_DIR)" + @echo "" + @echo "To publish, run:" + @echo " make publish PROJECT=test1" + @echo " make publish PROJECT=test2" + +# Publish: Publish a project to the index +.PHONY: publish +publish: + @if [ -z "$(PROJECT)" ]; then \ + echo "Error: PROJECT variable not set"; \ + echo "Usage: make publish PROJECT=test1"; \ + exit 1; \ + fi + @if [ -z "$$SYSAND_CRED_ALPHA_BEARER_TOKEN" ]; then \ + echo "Error: Authentication not configured"; \ + echo ""; \ + echo "Please set the following environment variables:"; \ + echo " export SYSAND_CRED_ALPHA='$(INDEX_URL)/**'"; \ + echo " export SYSAND_CRED_ALPHA_BEARER_TOKEN='sysand_u_your_token_here'"; \ + echo ""; \ + echo "Then run: make publish PROJECT=$(PROJECT)"; \ + exit 1; \ + fi + @if [ ! -d "$(TEST_ROOT)/$(PROJECT)" ]; then \ + echo "Error: Project $(PROJECT) not found in $(TEST_ROOT)"; \ + echo "Run 'make setup' first"; \ + exit 1; \ + fi + @echo "==> Publishing $(PROJECT) to $(INDEX_URL)..." + @cd $(TEST_ROOT)/$(PROJECT) && $(SYSAND) publish --default-index $(INDEX_URL) + @echo "✓ $(PROJECT) published successfully" + +# Test index usage: Create a consumer project that depends on test2 from the index +.PHONY: test-index-use +test-index-use: + @echo "==> Setting up consumer project in $(TEST_USE_ROOT)" + @rm -rf $(TEST_USE_ROOT) + @mkdir -p $(TEST_USE_DIR) + + @echo "" + @echo "==> Creating consumer project..." + @cd $(TEST_USE_DIR) && $(SYSAND) init --name consumer --version 0.1.0 + + @echo "" + @echo "==> Adding dependency on pkg:sysand/test2 from index..." + @cd $(TEST_USE_DIR) && $(SYSAND) add "pkg:sysand/test2" "^0.1.0" --default-index $(INDEX_READ_URL) + + @echo "" + @echo "==> Creating model file that uses test2..." + @echo "package ConsumerPackage {" > $(TEST_USE_DIR)/consumer.sysml + @echo " import Test2Package::*;" >> $(TEST_USE_DIR)/consumer.sysml + @echo " " >> $(TEST_USE_DIR)/consumer.sysml + @echo " part def ConsumerPart {" >> $(TEST_USE_DIR)/consumer.sysml + @echo " part myPart : MyPart;" >> $(TEST_USE_DIR)/consumer.sysml + @echo " doc /* Uses MyPart from test2, which uses ExamplePart from test1 */" >> $(TEST_USE_DIR)/consumer.sysml + @echo " }" >> $(TEST_USE_DIR)/consumer.sysml + @echo "}" >> $(TEST_USE_DIR)/consumer.sysml + + @cd $(TEST_USE_DIR) && $(SYSAND) include consumer.sysml + @cd $(TEST_USE_DIR) && $(SYSAND) build + + @echo "" + @echo "==> Test index usage complete!" + @echo " Consumer project: $(TEST_USE_DIR)" + @echo " Dependencies resolved from: $(INDEX_READ_URL)" + @echo "" + @echo "To inspect:" + @echo " cd $(TEST_USE_DIR)" + @echo " $(SYSAND) info" + +# Clean: Remove test directories +.PHONY: clean +clean: + @echo "==> Cleaning test directories..." + @rm -rf $(TEST_ROOT) + @rm -rf $(TEST_USE_ROOT) + @echo "✓ Cleaned $(TEST_ROOT) and $(TEST_USE_ROOT)" + +# Info: Show project information +.PHONY: info +info: + @echo "==> Test1 Project Info:" + @if [ -d "$(TEST1_DIR)" ]; then \ + cd $(TEST1_DIR) && $(SYSAND) info; \ + else \ + echo "Not initialized. Run 'make setup' first."; \ + fi + @echo "" + @echo "==> Test2 Project Info:" + @if [ -d "$(TEST2_DIR)" ]; then \ + cd $(TEST2_DIR) && $(SYSAND) info; \ + else \ + echo "Not initialized. Run 'make setup' first."; \ + fi diff --git a/bindings/java/java/src/main/java/com/sensmetry/sysand/model/InterchangeProjectInfo.java b/bindings/java/java/src/main/java/com/sensmetry/sysand/model/InterchangeProjectInfo.java index 82c2cce6..fe016605 100644 --- a/bindings/java/java/src/main/java/com/sensmetry/sysand/model/InterchangeProjectInfo.java +++ b/bindings/java/java/src/main/java/com/sensmetry/sysand/model/InterchangeProjectInfo.java @@ -7,6 +7,7 @@ public class InterchangeProjectInfo { private String name; + private String publisher; private String description; private String version; private String license; @@ -17,6 +18,7 @@ public class InterchangeProjectInfo { public InterchangeProjectInfo( String name, + String publisher, String description, String version, String license, @@ -26,6 +28,7 @@ public InterchangeProjectInfo( InterchangeProjectUsage[] usage ) { this.name = name; + this.publisher = publisher; this.description = description; this.version = version; this.license = license; @@ -39,6 +42,10 @@ public String getName() { return name; } + public String getPublisher() { + return publisher; + } + public String getDescription() { return description; } diff --git a/bindings/java/src/conversion.rs b/bindings/java/src/conversion.rs index e087acfb..b6ce8b12 100644 --- a/bindings/java/src/conversion.rs +++ b/bindings/java/src/conversion.rs @@ -17,7 +17,7 @@ pub(crate) const INTERCHANGE_PROJECT_USAGE_CLASS: &str = "com/sensmetry/sysand/model/InterchangeProjectUsage"; pub(crate) const INTERCHANGE_PROJECT_INFO_CLASS: &str = "com/sensmetry/sysand/model/InterchangeProjectInfo"; -pub(crate) const INTERCHANGE_PROJECT_INFO_CLASS_CONSTRUCTOR: &str = "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;[Ljava/lang/String;Ljava/lang/String;[Ljava/lang/String;[Lcom/sensmetry/sysand/model/InterchangeProjectUsage;)V"; +pub(crate) const INTERCHANGE_PROJECT_INFO_CLASS_CONSTRUCTOR: &str = "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;[Ljava/lang/String;Ljava/lang/String;[Ljava/lang/String;[Lcom/sensmetry/sysand/model/InterchangeProjectUsage;)V"; pub(crate) const INTERCHANGE_PROJECT_METADATA_CLASS: &str = "com/sensmetry/sysand/model/InterchangeProjectMetadata"; pub(crate) const INTERCHANGE_PROJECT_METADATA_CLASS_CONSTRUCTOR: &str = "(Ljava/util/LinkedHashMap;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Boolean;Ljava/lang/Boolean;Ljava/util/LinkedHashMap;)V"; @@ -260,6 +260,7 @@ impl ToJObject for Vec { impl ToJObject for InterchangeProjectInfoRaw { fn to_jobject<'local>(&self, env: &mut JNIEnv<'local>) -> Option> { let name = self.name.to_jobject(env)?; + let publisher = self.publisher.to_jobject(env)?; let description = self.description.to_jobject(env)?; let version = self.version.to_jobject(env)?; let license = self.license.to_jobject(env)?; @@ -272,6 +273,7 @@ impl ToJObject for InterchangeProjectInfoRaw { INTERCHANGE_PROJECT_INFO_CLASS_CONSTRUCTOR, &[ JValue::from(&name), + JValue::from(&publisher), JValue::from(&description), JValue::from(&version), JValue::from(&license), diff --git a/bindings/java/src/lib.rs b/bindings/java/src/lib.rs index f85902e9..0d4398c1 100644 --- a/bindings/java/src/lib.rs +++ b/bindings/java/src/lib.rs @@ -63,7 +63,7 @@ pub extern "system" fn Java_com_sensmetry_sysand_Sysand_init<'local>( }, }; - let command_result = commands::init::do_init_local_file(name, version, license, path.into()); + let command_result = commands::init::do_init_local_file(name, None, version, license, path.into()); match command_result { Ok(_) => {} Err(error) => match error { diff --git a/bindings/js/src/lib.rs b/bindings/js/src/lib.rs index 01d9dc86..67748d7c 100644 --- a/bindings/js/src/lib.rs +++ b/bindings/js/src/lib.rs @@ -43,6 +43,7 @@ pub fn do_new_js_local_storage( do_init( name, + None, version, license, &mut io::local_storage::ProjectLocalBrowserStorage { diff --git a/bindings/js/tests/basic_browser.rs b/bindings/js/tests/basic_browser.rs index eac01d06..7e1becaf 100644 --- a/bindings/js/tests/basic_browser.rs +++ b/bindings/js/tests/basic_browser.rs @@ -58,6 +58,7 @@ mod browser_tests { info, InterchangeProjectInfo { name: "test_basic_new".to_string(), + publisher: None, description: None, version: Version::parse("1.2.3")?, license: Some("MIT OR Apache-2.0".to_string()), diff --git a/bindings/py/src/lib.rs b/bindings/py/src/lib.rs index aa26c975..96a1fb0e 100644 --- a/bindings/py/src/lib.rs +++ b/bindings/py/src/lib.rs @@ -64,7 +64,7 @@ fn do_new_py_local_file( // library from python runs it let _ = pyo3_log::try_init(); - do_init_local_file(name, version, license, Utf8PathBuf::from(path)).map_err( + do_init_local_file(name, None, version, license, Utf8PathBuf::from(path)).map_err( |err| match err { InitError::SemVerParse(..) => PyValueError::new_err(err.to_string()), InitError::SPDXLicenseParse(..) => PyValueError::new_err(err.to_string()), diff --git a/core/Cargo.toml b/core/Cargo.toml index 32b84663..6018376d 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -40,7 +40,7 @@ log = { version = "0.4.29", default-features = false } pubgrub = { version = "0.3.0", default-features = false } # partialzip = { version = "5.0.0", default-features = false, optional = true } pyo3 = { version = "0.27.2", default-features = false, features = ["macros", "chrono", "indexmap"], optional = true } -reqwest-middleware = { version = "0.5.0" } +reqwest-middleware = { version = "0.5.0", features = ["multipart"] } semver = { version = "1.0.27", features = ["serde"] } serde = { version = "1.0.228", features = ["derive"] } serde_json = { version = "1.0.145", default-features = false, features = ["preserve_order"] } @@ -65,9 +65,9 @@ globset = { version = "0.4.18", default-features = false } # Use native TLS only on Windows and Apple OSs [target.'cfg(any(target_os = "windows", target_vendor = "apple"))'.dependencies] -reqwest = { version = "0.13.1", optional = true, default-features = false, features = ["native-tls", "http2", "system-proxy", "stream"] } +reqwest = { version = "0.13.1", optional = true, default-features = false, features = ["native-tls", "http2", "system-proxy", "stream", "multipart"] } [target.'cfg(not(any(target_os = "windows", target_vendor = "apple")))'.dependencies] -reqwest = { version = "0.13.1", optional = true, features = ["rustls", "stream"] } +reqwest = { version = "0.13.1", optional = true, features = ["rustls", "stream", "multipart"] } [dev-dependencies] assert_cmd = "2.1.1" diff --git a/core/src/commands/init.rs b/core/src/commands/init.rs index d3fd01ee..185372fa 100644 --- a/core/src/commands/init.rs +++ b/core/src/commands/init.rs @@ -29,6 +29,7 @@ pub enum InitError { pub fn do_init_ext( name: String, + publisher: Option, version: String, no_semver: bool, license: Option, @@ -55,6 +56,7 @@ pub fn do_init_ext( storage.put_project( &InterchangeProjectInfoRaw { name: name.to_owned(), + publisher: publisher.to_owned(), description: None, version: version.to_owned(), license, @@ -80,15 +82,17 @@ pub fn do_init_ext( pub fn do_init( name: String, + publisher: Option, version: String, license: Option, storage: &mut P, ) -> Result<(), InitError> { - do_init_ext(name, version, false, license, false, storage) + do_init_ext(name, publisher, version, false, license, false, storage) } pub fn do_init_memory, V: AsRef>( name: N, + publisher: Option, version: V, license: Option, ) -> Result> { @@ -96,6 +100,7 @@ pub fn do_init_memory, V: AsRef>( do_init( name.as_ref().to_owned(), + publisher, version.as_ref().to_owned(), license, &mut storage, @@ -107,6 +112,7 @@ pub fn do_init_memory, V: AsRef>( #[cfg(feature = "filesystem")] pub fn do_init_local_file( name: String, + publisher: Option, version: String, license: Option, path: Utf8PathBuf, @@ -116,7 +122,7 @@ pub fn do_init_local_file( project_path: path, }; - do_init(name, version, license, &mut storage)?; + do_init(name, publisher, version, license, &mut storage)?; Ok(storage) } diff --git a/core/src/commands/mod.rs b/core/src/commands/mod.rs index d3a93171..4ae0433c 100644 --- a/core/src/commands/mod.rs +++ b/core/src/commands/mod.rs @@ -10,6 +10,8 @@ pub mod include; pub mod info; pub mod init; pub mod lock; +#[cfg(all(feature = "filesystem", feature = "networking"))] +pub mod publish; pub mod remove; #[cfg(feature = "filesystem")] pub mod root; diff --git a/core/src/commands/publish.rs b/core/src/commands/publish.rs new file mode 100644 index 00000000..2773cd23 --- /dev/null +++ b/core/src/commands/publish.rs @@ -0,0 +1,138 @@ +// SPDX-FileCopyrightText: © 2025 Sysand contributors +// SPDX-License-Identifier: MIT OR Apache-2.0 + +use std::sync::Arc; + +use camino::Utf8Path; +use thiserror::Error; + +use crate::{ + auth::HTTPAuthentication, + model::normalize_for_id, + project::{ProjectRead, local_kpar::LocalKParProject}, +}; + +#[derive(Error, Debug)] +pub enum PublishError { + #[error("failed to read kpar file at `{0}`: {1}")] + KparRead(Box, std::io::Error), + + #[error("failed to open kpar project at `{0}`: {1}")] + KparOpen(Box, String), + + #[error("missing project info in kpar")] + MissingInfo, + + #[error("missing project metadata in kpar")] + MissingMeta, + + #[error("missing publisher in project info (required for publishing)")] + MissingPublisher, + + #[error("HTTP request failed: {0}")] + Http(#[from] reqwest_middleware::Error), + + #[error("server error ({0}): {1}")] + ServerError(u16, String), + + #[error("authentication failed: {0}")] + AuthError(String), + + #[error("conflict: package version already exists: {0}")] + Conflict(String), + + #[error("bad request: {0}")] + BadRequest(String), +} + +#[derive(Debug)] +pub struct PublishResponse { + pub status: u16, + pub message: String, + pub is_new_project: bool, +} + +pub fn do_publish_kpar, Policy: HTTPAuthentication>( + kpar_path: P, + index_url: &str, + auth_policy: Arc, + client: reqwest_middleware::ClientWithMiddleware, + runtime: Arc, +) -> Result { + let kpar_path = kpar_path.as_ref(); + let header = crate::style::get_style_config().header; + + // Open and validate kpar + let kpar_project = LocalKParProject::new_guess_root(kpar_path) + .map_err(|e| PublishError::KparOpen(kpar_path.as_str().into(), e.to_string()))?; + + let (info, meta) = kpar_project + .get_project() + .map_err(|e| PublishError::KparOpen(kpar_path.as_str().into(), e.to_string()))?; + + let info = info.ok_or(PublishError::MissingInfo)?; + let _meta = meta.ok_or(PublishError::MissingMeta)?; + + let publisher = info.publisher.as_deref().ok_or(PublishError::MissingPublisher)?; + let name = &info.name; + let version = &info.version; + let normalized_publisher = normalize_for_id(publisher); + let normalized_name = normalize_for_id(name); + let purl = format!("pkg:sysand/{normalized_publisher}/{normalized_name}@{version}"); + + let publishing = "Publishing"; + log::info!("{header}{publishing:>12}{header:#} `{name}` {version} to {index_url}"); + + // Read kpar file bytes + let file_bytes = std::fs::read(kpar_path) + .map_err(|e| PublishError::KparRead(kpar_path.as_str().into(), e))?; + + let file_name = kpar_path.file_name().unwrap_or("package.kpar").to_string(); + + let upload_url = format!("{}/api/v1/upload", index_url.trim_end_matches('/')); + + // Wrap in Arc for the 'static bound on the with_authentication closure + let file_bytes = Arc::new(file_bytes); + let file_name = Arc::new(file_name); + let upload_url = Arc::new(upload_url); + let purl = Arc::new(purl); + + let request_builder = move |c: &reqwest_middleware::ClientWithMiddleware| { + let file_part = reqwest::multipart::Part::bytes((*file_bytes).clone()) + .file_name((*file_name).clone()) + .mime_str("application/octet-stream") + .expect("valid mime type"); + + let form = reqwest::multipart::Form::new() + .text("purl", (*purl).clone()) + .part("file", file_part); + + c.post(upload_url.as_str()).multipart(form) + }; + + let response = runtime.block_on(async { + auth_policy + .with_authentication(&client, &request_builder) + .await + })?; + + let status = response.status().as_u16(); + let body = runtime.block_on(response.text()).unwrap_or_default(); + + match status { + 200 => Ok(PublishResponse { + status, + message: body, + is_new_project: false, + }), + 201 => Ok(PublishResponse { + status, + message: body, + is_new_project: true, + }), + 401 | 403 => Err(PublishError::AuthError(body)), + 409 => Err(PublishError::Conflict(body)), + 400 | 404 => Err(PublishError::BadRequest(body)), + _ => Err(PublishError::ServerError(status, body)), + } +} diff --git a/core/src/commands/sync.rs b/core/src/commands/sync.rs index 809cbe1a..91bc7386 100644 --- a/core/src/commands/sync.rs +++ b/core/src/commands/sync.rs @@ -345,6 +345,7 @@ mod tests { .put_project( &InterchangeProjectInfo { name: "install_test".to_string(), + publisher: None, description: None, version: Version::new(1, 2, 3), license: None, diff --git a/core/src/env/memory.rs b/core/src/env/memory.rs index d7dd855b..c7aaa86b 100644 --- a/core/src/env/memory.rs +++ b/core/src/env/memory.rs @@ -73,8 +73,8 @@ pub enum TryFromError { /// # use sysand_core::env::memory::MemoryStorageEnvironment; /// # use sysand_core::env::ReadEnvironment; /// # use sysand_core::project::memory::InMemoryProject; -/// let project1 = do_init_memory("First", "0.0.1", None).unwrap(); -/// let project2 = do_init_memory("First", "0.1.0", None).unwrap(); +/// let project1 = do_init_memory("First", None, "0.0.1", None).unwrap(); +/// let project2 = do_init_memory("First", None, "0.1.0", None).unwrap(); /// let env = MemoryStorageEnvironment::::try_from([ /// ("urn:kpar:first".into(), project1.clone()), /// ("urn:kpar:first".into(), project2.clone()), @@ -120,8 +120,8 @@ impl TryFrom<[(String, Project); N /// # use sysand_core::env::memory::MemoryStorageEnvironment; /// # use sysand_core::env::ReadEnvironment; /// # use sysand_core::project::memory::InMemoryProject; -/// let project1 = do_init_memory("First", "0.0.1", None).unwrap(); -/// let project2 = do_init_memory("First", "0.1.0", None).unwrap(); +/// let project1 = do_init_memory("First", None, "0.0.1", None).unwrap(); +/// let project2 = do_init_memory("First", None, "0.1.0", None).unwrap(); /// let env = MemoryStorageEnvironment::::try_from(vec![ /// ("urn:kpar:first".into(), project1.clone()), /// ("urn:kpar:first".into(), project2.clone()), @@ -177,8 +177,8 @@ impl FromIterator<(String, String, Project)> /// # use sysand_core::project::memory::InMemoryProject; /// let version1 = "0.0.1".to_string(); /// let version2 = "0.1.0".to_string(); -/// let project1 = do_init_memory("First", &version1, None).unwrap(); -/// let project2 = do_init_memory("First", &version2, None).unwrap(); +/// let project1 = do_init_memory("First", None, &version1, None).unwrap(); +/// let project2 = do_init_memory("First", None, &version2, None).unwrap(); /// let env = MemoryStorageEnvironment::::from([ /// ("urn:kpar:first".into(), version1.clone(), project1.clone()), /// ("urn:kpar:first".into(), version2.clone(), project2.clone()), @@ -219,8 +219,8 @@ impl From<[(String, String, Projec /// # use sysand_core::project::memory::InMemoryProject; /// let version1 = "0.0.1".to_string(); /// let version2 = "0.1.0".to_string(); -/// let project1 = do_init_memory("First", &version1, None).unwrap(); -/// let project2 = do_init_memory("First", &version2, None).unwrap(); +/// let project1 = do_init_memory("First", None, &version1, None).unwrap(); +/// let project2 = do_init_memory("First", None, &version2, None).unwrap(); /// let env = MemoryStorageEnvironment::::from(vec![ /// ("urn:kpar:first".into(), version1.clone(), project1.clone()), /// ("urn:kpar:first".into(), version2.clone(), project2.clone()), @@ -372,8 +372,8 @@ mod test { let uri1 = "urn:kpar:first".to_string(); let uri2 = "urn:kpar:second".to_string(); let version = "0.0.1".to_string(); - let project1 = do_init_memory("First", &version, None).unwrap(); - let project2 = do_init_memory("Second", &version, None).unwrap(); + let project1 = do_init_memory("First", Some("Test Publisher".to_string()), &version, None).unwrap(); + let project2 = do_init_memory("Second", Some("test-publisher".to_string()), &version, None).unwrap(); let mut env = MemoryStorageEnvironment::::new(); env.put_project(&uri1, &version, |p| { @@ -417,7 +417,7 @@ mod test { fn read_environment() { let iri = "urn:kpar:first".to_string(); let version = "0.0.1".to_string(); - let project = do_init_memory("First", &version, None).unwrap(); + let project = do_init_memory("First", Some("Test Publisher".to_string()), &version, None).unwrap(); let env = MemoryStorageEnvironment { projects: HashMap::from([( iri.clone(), @@ -451,9 +451,9 @@ mod test { let version1 = "0.0.1".to_string(); let version2 = "0.1.0".to_string(); let version3 = "0.0.1".to_string(); - let project1 = do_init_memory("First 0.0.1", &version1, None).unwrap(); - let project2 = do_init_memory("First 0.1.0", &version2, None).unwrap(); - let project3 = do_init_memory("Second", &version3, None).unwrap(); + let project1 = do_init_memory("First 0.0.1", Some("test-publisher".to_string()), &version1, None).unwrap(); + let project2 = do_init_memory("First 0.1.0", None, &version2, None).unwrap(); + let project3 = do_init_memory("Second", Some("Test Publisher".to_string()), &version3, None).unwrap(); let env = MemoryStorageEnvironment::::from([ ("urn:kpar:first".into(), version1.clone(), project1.clone()), ("urn:kpar:first".into(), version2.clone(), project2.clone()), @@ -478,9 +478,9 @@ mod test { #[test] fn try_from() { - let project1 = do_init_memory("First 0.0.1", "0.0.1", None).unwrap(); - let project2 = do_init_memory("First 0.1.0", "0.1.0", None).unwrap(); - let project3 = do_init_memory("Second", "0.0.1", None).unwrap(); + let project1 = do_init_memory("First 0.0.1", None, "0.0.1", None).unwrap(); + let project2 = do_init_memory("First 0.1.0", Some("Test Publisher".to_string()), "0.1.0", None).unwrap(); + let project3 = do_init_memory("Second", None, "0.0.1", None).unwrap(); let env = MemoryStorageEnvironment::::try_from([ ("urn:kpar:first".into(), project1.clone()), ("urn:kpar:first".into(), project2.clone()), diff --git a/core/src/model.rs b/core/src/model.rs index 88d7dd5b..2d52ef7d 100644 --- a/core/src/model.rs +++ b/core/src/model.rs @@ -75,6 +75,10 @@ impl TryFrom for InterchangeProjectUsage { pub struct InterchangeProjectInfoG { pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(default)] + pub publisher: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub description: Option, @@ -105,6 +109,7 @@ impl From for InterchangeProjectInfoRaw { fn from(value: InterchangeProjectInfo) -> Self { InterchangeProjectInfoRaw { name: value.name, + publisher: value.publisher, description: value.description, version: value.version.to_string(), license: value.license, @@ -126,6 +131,7 @@ impl pub fn minimal(name: String, version: Version) -> Self { InterchangeProjectInfoG { name, + publisher: None, description: None, version, license: None, @@ -173,6 +179,7 @@ impl InterchangeProjectInfoRaw { Ok(InterchangeProjectInfo { name: self.name.clone(), + publisher: self.publisher.clone(), description: self.description.clone(), version: semver::Version::parse(&self.version).map_err(|e| { InterchangeProjectValidationError::SemVerParse(self.version.as_str().into(), e) @@ -200,6 +207,12 @@ impl TryFrom for InterchangeProjectInfo { } } +/// Normalize a string for use in identifiers (e.g. PURL components): +/// lowercase and replace spaces with hyphens. +pub fn normalize_for_id(s: &str) -> String { + s.to_lowercase().replace(' ', "-") +} + /// KerML 1.0, 10.3 note 6, page 409: /// Valid values for the checksum algorithm are: /// - SHA1, SHA224, SHA256, SHA-384, SHA3-256, SHA3-384, SHA3-512 @@ -682,6 +695,7 @@ mod tests { fn json_hash_agrees_with_shell() { let info = InterchangeProjectInfoRaw { name: "json_hash_agrees_with_shell".to_string(), + publisher: None, description: None, version: "1.2.3".to_string(), license: None, diff --git a/core/src/project/mod.rs b/core/src/project/mod.rs index 88336c33..5b7aa847 100644 --- a/core/src/project/mod.rs +++ b/core/src/project/mod.rs @@ -1133,7 +1133,8 @@ mod tests { fn test_canonicalization_no_checksums() -> Result<(), Box> { let project = InMemoryProject { info: Some(InterchangeProjectInfoRaw { - name: "test_canonicalization".to_string(), + name: "test_canonicalisation".to_string(), + publisher: None, description: None, version: "1.2.3".to_string(), license: None, diff --git a/core/src/resolve/combined.rs b/core/src/resolve/combined.rs index c3e71f69..4f2850fd 100644 --- a/core/src/resolve/combined.rs +++ b/core/src/resolve/combined.rs @@ -436,6 +436,7 @@ mod tests { InMemoryProject { info: Some(InterchangeProjectInfoRaw { name: name.as_ref().to_string(), + publisher: None, description: None, version: version.as_ref().to_string(), license: None, diff --git a/core/src/resolve/priority.rs b/core/src/resolve/priority.rs index 55b59af6..18c4c9fd 100644 --- a/core/src/resolve/priority.rs +++ b/core/src/resolve/priority.rs @@ -212,6 +212,7 @@ mod tests { InMemoryProject { info: Some(InterchangeProjectInfoRaw { name: name.as_ref().to_string(), + publisher: None, description: None, version: version.as_ref().to_string(), license: None, diff --git a/core/src/resolve/sequential.rs b/core/src/resolve/sequential.rs index 4290a55e..36d412cc 100644 --- a/core/src/resolve/sequential.rs +++ b/core/src/resolve/sequential.rs @@ -150,6 +150,7 @@ mod tests { InMemoryProject { info: Some(InterchangeProjectInfoRaw { name: name.as_ref().to_string(), + publisher: None, description: None, version: version.as_ref().to_string(), license: None, diff --git a/core/src/solve/pubgrub.rs b/core/src/solve/pubgrub.rs index 5ad58167..800e0dcf 100644 --- a/core/src/solve/pubgrub.rs +++ b/core/src/solve/pubgrub.rs @@ -517,6 +517,7 @@ mod tests { InMemoryProject { info: Some(InterchangeProjectInfoRaw { name: name.to_string(), + publisher: None, description: None, version: version.to_string(), license: None, diff --git a/core/tests/filesystem_env.rs b/core/tests/filesystem_env.rs index 4d9ad276..cd372d7d 100644 --- a/core/tests/filesystem_env.rs +++ b/core/tests/filesystem_env.rs @@ -92,6 +92,7 @@ mod filesystem_tests { let info = InterchangeProjectInfo { name: "env_manual_install".to_string(), + publisher: Some("test-publisher".to_string()), description: None, version: Version::new(1, 2, 3), license: None, diff --git a/core/tests/memory_env.rs b/core/tests/memory_env.rs index 78468977..a0c5ff03 100644 --- a/core/tests/memory_env.rs +++ b/core/tests/memory_env.rs @@ -40,6 +40,7 @@ fn env_manual_install() -> Result<(), Box> { let info = InterchangeProjectInfo { name: "env_manual_install".to_string(), + publisher: Some("Test Publisher".to_string()), description: None, version: Version::new(1, 2, 3), license: None, diff --git a/core/tests/memory_init.rs b/core/tests/memory_init.rs index cb3e0e87..1264b77b 100644 --- a/core/tests/memory_init.rs +++ b/core/tests/memory_init.rs @@ -8,12 +8,13 @@ use sysand_core::{commands::init::do_init, init::do_init_memory, model::Intercha /// and .meta.json files in the current working directory. (Non-interactive use) #[test] fn init_basic() -> Result<(), Box> { - let memory_storage = do_init_memory("init_basic", "1.2.3", Some("Apache-2.0".to_string()))?; + let memory_storage = do_init_memory("init_basic", Some("Test Publisher".to_string()), "1.2.3", Some("Apache-2.0".to_string()))?; assert_eq!( memory_storage.info.unwrap(), InterchangeProjectInfo { name: "init_basic".to_string(), + publisher: Some("Test Publisher".to_string()), description: None, version: Version::parse("1.2.3").unwrap(), license: Some("Apache-2.0".to_string()), @@ -56,6 +57,7 @@ fn init_basic() -> Result<(), Box> { fn init_fail_on_double_init() -> Result<(), Box> { let mut memory_storage = do_init_memory( "init_fail_on_double_init", + Some("test-publisher".to_string()), "1.2.3", Some("Apache-2.0 OR MIT".to_string()), )?; @@ -65,6 +67,7 @@ fn init_fail_on_double_init() -> Result<(), Box> { let second_result = do_init( "init_fail_on_double_init".to_string(), + Some("test-publisher".to_string()), "1.2.3".to_string(), Some("Apache-2.0 OR MIT".to_string()), &mut memory_storage, diff --git a/core/tests/project_derive.rs b/core/tests/project_derive.rs index 249cac36..dfa6b859 100644 --- a/core/tests/project_derive.rs +++ b/core/tests/project_derive.rs @@ -55,6 +55,7 @@ fn test_error_to_string() { fn test_macro_get_project() { let info = InterchangeProjectInfoRaw { name: "get_project".to_string(), + publisher: Some("Test Publisher".to_string()), description: None, version: "1.2.3".to_string(), license: None, @@ -117,6 +118,7 @@ fn test_macro_sources() { fn test_macro_put_info() { let info = InterchangeProjectInfoRaw { name: "single_get_info".to_string(), + publisher: Some("test-publisher".to_string()), description: None, version: "1.2.3".to_string(), license: None, diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index 57e75320..0ed2d3bb 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -14,6 +14,7 @@ - [sysand include](commands/include.md) - [sysand exclude](commands/exclude.md) - [sysand build](commands/build.md) + - [sysand publish](commands/publish.md) - [sysand lock](commands/lock.md) - [sysand env](commands/env.md) - [sysand env install](commands/env/install.md) diff --git a/docs/src/commands/publish.md b/docs/src/commands/publish.md new file mode 100644 index 00000000..a873ac2c --- /dev/null +++ b/docs/src/commands/publish.md @@ -0,0 +1,57 @@ +# `sysand publish` + +Publish a KPAR to the sysand package index. + +## Usage + +```sh +sysand publish [OPTIONS] [PATH] +``` + +## Description + +Publishes a `.kpar` file to a sysand-compatible package index. The project +must be built first using [`sysand build`](build.md). + +Authentication is required. See [Authentication](../authentication.md) for +how to configure credentials. + +## Arguments + +- `[PATH]`: Path to the `.kpar` file to publish. If not provided, looks for + a KPAR in the output directory matching the current project's name and + version (e.g. `output/-.kpar`). + +## Options + +- `--index `: URL of the package index to publish to. Defaults to the + first index URL from configuration or `https://beta.sysand.org`. + +{{#include ./partials/global_opts.md}} + +## Examples + +Build and publish the current project: + +```sh +sysand build +sysand publish +``` + +Publish a specific KPAR file: + +```sh +sysand publish ./my-project-1.0.0.kpar +``` + +Publish to a custom index: + +```sh +sysand publish --index https://my-index.example.com +``` + +## See Also + +- [`sysand build`](build.md) — Build a KPAR from a project +- [Authentication](../authentication.md) — Configure credentials +- [Publishing a package](../publishing.md) — Publishing guide diff --git a/sysand/Cargo.toml b/sysand/Cargo.toml index 578b48bb..ec1c8209 100644 --- a/sysand/Cargo.toml +++ b/sysand/Cargo.toml @@ -39,13 +39,13 @@ zip = { version = "6.0.0" } pubgrub = { version = "0.3.0", default-features = false } indexmap = "2.12.1" tokio = { version = "1.48.0", default-features = false } -reqwest-middleware = { version = "0.5.0" } +reqwest-middleware = { version = "0.5.0", features = ["multipart"] } # Use native TLS only on Windows and Apple OSs [target.'cfg(any(target_os = "windows", target_vendor = "apple"))'.dependencies] -reqwest = { version = "0.13.1", default-features = false, features = ["native-tls", "http2", "system-proxy", "blocking"] } +reqwest = { version = "0.13.1", default-features = false, features = ["native-tls", "http2", "system-proxy", "blocking", "multipart"] } [target.'cfg(not(any(target_os = "windows", target_vendor = "apple")))'.dependencies] -reqwest = { version = "0.13.1", features = ["rustls", "blocking"] } +reqwest = { version = "0.13.1", features = ["rustls", "blocking", "multipart"] } [dev-dependencies] assert_cmd = "2.1.1" diff --git a/sysand/src/cli.rs b/sysand/src/cli.rs index a9b270a9..b0663b88 100644 --- a/sysand/src/cli.rs +++ b/sysand/src/cli.rs @@ -63,6 +63,9 @@ pub enum Command { /// The name of the project. Defaults to the directory name #[arg(long)] name: Option, + /// Set the publisher of the project + #[arg(long)] + publisher: Option, /// Set the version in SemVer 2.0 format. Defaults to `0.0.1` #[arg(long)] version: Option, @@ -148,6 +151,19 @@ pub enum Command { #[arg(num_args = 1..)] paths: Vec, }, + /// Publish a KPAR to the sysand package index + Publish { + /// Path to the KPAR file to publish. If not provided, will look + /// for a KPAR in the output directory with the current project's + /// name and version + #[clap(verbatim_doc_comment)] + path: Option, + + /// URL of the package index to publish to. Defaults to the + /// first index URL from configuration or https://beta.sysand.org + #[arg(long, verbatim_doc_comment)] + index: Option, + }, /// Build a KerML Project Archive (KPAR). If executed in a workspace /// outside of a project, builds all projects in the workspace. #[clap(verbatim_doc_comment)] diff --git a/sysand/src/commands/init.rs b/sysand/src/commands/init.rs index 77bf77a5..bb6d229f 100644 --- a/sysand/src/commands/init.rs +++ b/sysand/src/commands/init.rs @@ -8,6 +8,7 @@ use sysand_core::project::utils::wrapfs; pub fn command_init( name: Option, + publisher: Option, version: Option, no_semver: bool, license: Option, @@ -30,6 +31,7 @@ pub fn command_init( sysand_core::init::do_init_ext( name, + publisher, version, no_semver, license, diff --git a/sysand/src/commands/mod.rs b/sysand/src/commands/mod.rs index 4de8a420..d87694a2 100644 --- a/sysand/src/commands/mod.rs +++ b/sysand/src/commands/mod.rs @@ -11,6 +11,7 @@ pub mod info; pub mod init; pub mod lock; pub mod print_root; +pub mod publish; pub mod remove; pub mod sources; pub mod sync; diff --git a/sysand/src/commands/publish.rs b/sysand/src/commands/publish.rs new file mode 100644 index 00000000..57b02f30 --- /dev/null +++ b/sysand/src/commands/publish.rs @@ -0,0 +1,28 @@ +// SPDX-FileCopyrightText: © 2025 Sysand contributors +// SPDX-License-Identifier: MIT OR Apache-2.0 + +use std::sync::Arc; + +use anyhow::Result; +use camino::Utf8Path; +use sysand_core::{auth::HTTPAuthentication, commands::publish::do_publish_kpar}; + +pub fn command_publish, Policy: HTTPAuthentication>( + kpar_path: P, + index_url: &str, + auth_policy: Arc, + client: reqwest_middleware::ClientWithMiddleware, + runtime: Arc, +) -> Result<()> { + let response = do_publish_kpar(kpar_path, index_url, auth_policy, client, runtime)?; + + let header = sysand_core::style::get_style_config().header; + let published = "Published"; + if response.is_new_project { + log::info!("{header}{published:>12}{header:#} new project successfully"); + } else { + log::info!("{header}{published:>12}{header:#} new release successfully"); + } + + Ok(()) +} diff --git a/sysand/src/lib.rs b/sysand/src/lib.rs index a97a2b73..40ccb4b9 100644 --- a/sysand/src/lib.rs +++ b/sysand/src/lib.rs @@ -52,6 +52,7 @@ use crate::{ init::command_init, lock::command_lock, print_root::command_print_root, + publish::command_publish, remove::command_remove, sources::{command_sources_env, command_sources_project}, sync::command_sync, @@ -249,11 +250,12 @@ pub fn run_cli(args: cli::Args) -> Result<()> { cli::Command::Init { path, name, + publisher, version, no_semver, license, no_spdx, - } => command_init(name, version, no_semver, license, no_spdx, path), + } => command_init(name, publisher, version, no_semver, license, no_spdx, path), cli::Command::Env { command } => match command { None => { let env_dir = { @@ -588,6 +590,26 @@ pub fn run_cli(args: cli::Args) -> Result<()> { no_index_symbols, } => command_include(paths, add_checksum, !no_index_symbols, current_project), cli::Command::Exclude { paths } => command_exclude(paths, current_project), + cli::Command::Publish { path, index } => { + let current_project = current_project.ok_or(CliError::MissingProjectCurrentDir)?; + let kpar_path = if let Some(path) = path { + path + } else { + let mut output_dir = current_workspace + .as_ref() + .map(|workspace| &workspace.workspace_path) + .unwrap_or(¤t_project.project_path) + .join("output"); + let name = sysand_core::build::default_kpar_file_name(¤t_project)?; + output_dir.push(name); + output_dir + }; + if !kpar_path.is_file() { + bail!("kpar file not found at `{kpar_path}`, run `sysand build` first"); + } + let index_url = index.unwrap_or_else(|| DEFAULT_INDEX_URL.to_string()); + command_publish(kpar_path, &index_url, basic_auth_policy, client, runtime) + } cli::Command::Build { path } => { if let Some(current_project) = current_project { // Even if we are in a workspace, the project takes precedence. diff --git a/sysand/tests/cli_publish.rs b/sysand/tests/cli_publish.rs new file mode 100644 index 00000000..1f17769b --- /dev/null +++ b/sysand/tests/cli_publish.rs @@ -0,0 +1,68 @@ +// SPDX-FileCopyrightText: © 2025 Sysand contributors +// SPDX-License-Identifier: MIT OR Apache-2.0 + +use assert_cmd::prelude::*; +use predicates::prelude::*; + +// pub due to https://github.com/rust-lang/rust/issues/46379 +mod common; +pub use common::*; + +#[test] +fn test_publish_missing_kpar() -> Result<(), Box> { + let (_temp_dir, cwd, out) = run_sysand( + ["init", "--version", "1.0.0", "--name", "test_publish"], + None, + )?; + out.assert().success(); + + let out = run_sysand_in(&cwd, ["publish"], None)?; + + out.assert() + .failure() + .stderr(predicate::str::contains("kpar file not found")) + .stderr(predicate::str::contains("sysand build")); + + Ok(()) +} + +#[test] +fn test_publish_explicit_missing_kpar() -> Result<(), Box> { + let (_temp_dir, cwd, out) = run_sysand( + ["init", "--version", "1.0.0", "--name", "test_publish"], + None, + )?; + out.assert().success(); + + let out = run_sysand_in(&cwd, ["publish", "nonexistent.kpar"], None)?; + + out.assert() + .failure() + .stderr(predicate::str::contains("kpar file not found")); + + Ok(()) +} + +#[test] +fn test_publish_network_error() -> Result<(), Box> { + let (_temp_dir, cwd, out) = run_sysand( + ["init", "--version", "1.0.0", "--name", "test_publish"], + None, + )?; + out.assert().success(); + + // Include a file and build + std::fs::write(cwd.join("test.sysml"), "package P;\n")?; + let out = run_sysand_in(&cwd, ["include", "--no-index-symbols", "test.sysml"], None)?; + out.assert().success(); + + let out = run_sysand_in(&cwd, ["build"], None)?; + out.assert().success(); + + // Try to publish to a non-existent server + let out = run_sysand_in(&cwd, ["publish", "--index", "http://localhost:1"], None)?; + + out.assert().failure(); + + Ok(()) +}