logicaffeine_cli/project/registry.rs
1//! Phase 39: Registry Client
2//!
3//! HTTP client for communicating with the LOGOS package registry.
4//!
5//! This module provides the [`RegistryClient`] for authenticated API calls to
6//! the package registry, along with supporting types for package metadata and
7//! error handling.
8//!
9//! # Architecture
10//!
11//! The registry client uses [`ureq`] for HTTP requests with Bearer token
12//! authentication. All requests are made over HTTPS to the configured registry
13//! URL (defaulting to `registry.logicaffeine.com`).
14//!
15//! # Example
16//!
17//! ```no_run
18//! use logicaffeine_cli::project::registry::{RegistryClient, PublishMetadata};
19//!
20//! let client = RegistryClient::new("https://registry.logicaffeine.com", "tok_xxx");
21//!
22//! // Validate authentication
23//! let user = client.validate_token()?;
24//! println!("Authenticated as: {}", user.login);
25//! # Ok::<(), Box<dyn std::error::Error>>(())
26//! ```
27
28use std::path::Path;
29
30const DEFAULT_REGISTRY_URL: &str = "https://registry.logicaffeine.com";
31
32/// HTTP client for the LOGOS package registry API.
33///
34/// Provides authenticated access to registry operations including:
35/// - Token validation
36/// - Package publishing
37///
38/// # Authentication
39///
40/// All API calls require a Bearer token, typically obtained via `largo login`.
41/// Tokens are validated against the registry's `/auth/me` endpoint.
42///
43/// # Example
44///
45/// ```no_run
46/// use logicaffeine_cli::project::registry::RegistryClient;
47///
48/// let client = RegistryClient::new(
49/// RegistryClient::default_url(),
50/// "tok_xxxxx"
51/// );
52///
53/// // Verify the token is valid
54/// match client.validate_token() {
55/// Ok(user) => println!("Logged in as {}", user.login),
56/// Err(e) => eprintln!("Auth failed: {}", e),
57/// }
58/// ```
59pub struct RegistryClient {
60 /// Base URL of the registry API (without trailing slash).
61 base_url: String,
62 /// Bearer token for authentication.
63 token: String,
64}
65
66impl RegistryClient {
67 /// Create a new registry client with the given URL and authentication token.
68 ///
69 /// # Arguments
70 ///
71 /// * `base_url` - The registry API base URL. Trailing slashes are stripped.
72 /// * `token` - Bearer token for authentication.
73 ///
74 /// # Example
75 ///
76 /// ```
77 /// use logicaffeine_cli::project::registry::RegistryClient;
78 ///
79 /// let client = RegistryClient::new(
80 /// "https://registry.logicaffeine.com",
81 /// "tok_xxxxx"
82 /// );
83 /// ```
84 pub fn new(base_url: &str, token: &str) -> Self {
85 Self {
86 base_url: base_url.trim_end_matches('/').to_string(),
87 token: token.to_string(),
88 }
89 }
90
91 /// Returns the default registry URL.
92 ///
93 /// Currently returns `https://registry.logicaffeine.com`.
94 pub fn default_url() -> &'static str {
95 DEFAULT_REGISTRY_URL
96 }
97
98 /// Validate the authentication token by querying the registry.
99 ///
100 /// Makes a request to `/auth/me` to verify the token is valid and
101 /// retrieve the associated user information.
102 ///
103 /// # Errors
104 ///
105 /// Returns [`RegistryError::Unauthorized`] if the token is invalid or expired.
106 /// Returns [`RegistryError::Network`] for connection failures.
107 pub fn validate_token(&self) -> Result<UserInfo, RegistryError> {
108 let url = format!("{}/auth/me", self.base_url);
109
110 let response = ureq::get(&url)
111 .set("Authorization", &format!("Bearer {}", self.token))
112 .call()
113 .map_err(|e| match e {
114 ureq::Error::Status(401, _) => RegistryError::Unauthorized,
115 ureq::Error::Status(403, r) => {
116 let msg = r.into_string().unwrap_or_default();
117 RegistryError::Forbidden(msg)
118 }
119 ureq::Error::Status(code, r) => RegistryError::Server {
120 status: code,
121 message: r.into_string().unwrap_or_default(),
122 },
123 e => RegistryError::Network(e.to_string()),
124 })?;
125
126 let user: UserInfo = response.into_json()
127 .map_err(|e| RegistryError::Network(e.to_string()))?;
128
129 Ok(user)
130 }
131
132 /// Publish a package to the registry.
133 ///
134 /// Uploads a package tarball with metadata to the registry's publish endpoint.
135 /// The request is sent as multipart form data.
136 ///
137 /// # Arguments
138 ///
139 /// * `name` - Package name (must match manifest)
140 /// * `version` - Semantic version string
141 /// * `tarball` - Gzipped tar archive of the package
142 /// * `metadata` - Package metadata for the registry index
143 ///
144 /// # Errors
145 ///
146 /// - [`RegistryError::Unauthorized`] - Invalid or missing token
147 /// - [`RegistryError::VersionExists`] - This version already published
148 /// - [`RegistryError::TooLarge`] - Package exceeds 10MB limit
149 /// - [`RegistryError::InvalidPackage`] - Metadata serialization failed
150 pub fn publish(
151 &self,
152 name: &str,
153 version: &str,
154 tarball: &[u8],
155 metadata: &PublishMetadata,
156 ) -> Result<PublishResult, RegistryError> {
157 let url = format!("{}/packages/publish", self.base_url);
158
159 // Create multipart form data
160 let boundary = format!("----LargoBoundary{}", rand::random::<u64>());
161
162 let metadata_json = serde_json::to_string(metadata)
163 .map_err(|e| RegistryError::InvalidPackage(e.to_string()))?;
164
165 let mut body = Vec::new();
166
167 // Add metadata field
168 body.extend_from_slice(format!(
169 "--{}\r\nContent-Disposition: form-data; name=\"metadata\"\r\n\r\n{}\r\n",
170 boundary, metadata_json
171 ).as_bytes());
172
173 // Add tarball field
174 body.extend_from_slice(format!(
175 "--{}\r\nContent-Disposition: form-data; name=\"tarball\"; filename=\"{}-{}.tar.gz\"\r\nContent-Type: application/gzip\r\n\r\n",
176 boundary, name, version
177 ).as_bytes());
178 body.extend_from_slice(tarball);
179 body.extend_from_slice(format!("\r\n--{}--\r\n", boundary).as_bytes());
180
181 let response = ureq::post(&url)
182 .set("Authorization", &format!("Bearer {}", self.token))
183 .set("Content-Type", &format!("multipart/form-data; boundary={}", boundary))
184 .send_bytes(&body)
185 .map_err(|e| match e {
186 ureq::Error::Status(401, _) => RegistryError::Unauthorized,
187 ureq::Error::Status(403, r) => {
188 let msg = r.into_string().unwrap_or_else(|_| "Forbidden".to_string());
189 RegistryError::Forbidden(msg)
190 }
191 ureq::Error::Status(409, _) => RegistryError::VersionExists {
192 name: name.to_string(),
193 version: version.to_string(),
194 },
195 ureq::Error::Status(413, _) => RegistryError::TooLarge,
196 ureq::Error::Status(code, r) => RegistryError::Server {
197 status: code,
198 message: r.into_string().unwrap_or_default(),
199 },
200 e => RegistryError::Network(e.to_string()),
201 })?;
202
203 let result: PublishResult = response.into_json()
204 .map_err(|e| RegistryError::Network(e.to_string()))?;
205
206 Ok(result)
207 }
208}
209
210/// Create a gzipped tarball from a LOGOS project.
211///
212/// Packages the project for upload to the registry. The tarball includes:
213/// - `Largo.toml` (required)
214/// - `src/` directory recursively (required)
215/// - `README.md` (if present)
216/// - `LICENSE` (if present)
217///
218/// Hidden files (starting with `.`) and the `target/` directory are excluded.
219/// Only `.lg`, `.md`, `.toml`, and `.json` files are included from `src/`.
220///
221/// # Arguments
222///
223/// * `project_dir` - Root directory of the LOGOS project
224///
225/// # Errors
226///
227/// Returns [`PackageError::MissingFile`] if `Largo.toml` or `src/` is missing.
228pub fn create_tarball(project_dir: &Path) -> Result<Vec<u8>, PackageError> {
229 use flate2::write::GzEncoder;
230 use flate2::Compression;
231 use tar::Builder;
232
233 let mut tarball = Vec::new();
234
235 {
236 let encoder = GzEncoder::new(&mut tarball, Compression::default());
237 let mut builder = Builder::new(encoder);
238
239 // Add Largo.toml
240 let manifest_path = project_dir.join("Largo.toml");
241 if !manifest_path.exists() {
242 return Err(PackageError::MissingFile("Largo.toml".to_string()));
243 }
244 add_file_to_tar(&mut builder, project_dir, "Largo.toml")?;
245
246 // Add src/ directory recursively
247 let src_dir = project_dir.join("src");
248 if !src_dir.exists() {
249 return Err(PackageError::MissingFile("src/".to_string()));
250 }
251 add_dir_recursive(&mut builder, project_dir, "src")?;
252
253 // Add README.md if it exists
254 if project_dir.join("README.md").exists() {
255 add_file_to_tar(&mut builder, project_dir, "README.md")?;
256 }
257
258 // Add LICENSE if it exists
259 if project_dir.join("LICENSE").exists() {
260 add_file_to_tar(&mut builder, project_dir, "LICENSE")?;
261 }
262
263 builder.finish()
264 .map_err(|e| PackageError::TarError(e.to_string()))?;
265 }
266
267 Ok(tarball)
268}
269
270fn add_file_to_tar<W: std::io::Write>(
271 builder: &mut tar::Builder<W>,
272 base_dir: &Path,
273 rel_path: &str,
274) -> Result<(), PackageError> {
275 let full_path = base_dir.join(rel_path);
276 let content = std::fs::read(&full_path)
277 .map_err(|e| PackageError::Io(format!("{}: {}", rel_path, e)))?;
278
279 let mut header = tar::Header::new_gnu();
280 header.set_path(rel_path)
281 .map_err(|e| PackageError::TarError(e.to_string()))?;
282 header.set_size(content.len() as u64);
283 header.set_mode(0o644);
284 header.set_mtime(0); // Reproducible builds
285 header.set_cksum();
286
287 builder.append(&header, content.as_slice())
288 .map_err(|e| PackageError::TarError(e.to_string()))?;
289
290 Ok(())
291}
292
293fn add_dir_recursive<W: std::io::Write>(
294 builder: &mut tar::Builder<W>,
295 base_dir: &Path,
296 rel_dir: &str,
297) -> Result<(), PackageError> {
298 let full_dir = base_dir.join(rel_dir);
299
300 for entry in std::fs::read_dir(&full_dir)
301 .map_err(|e| PackageError::Io(format!("{}: {}", rel_dir, e)))?
302 {
303 let entry = entry.map_err(|e| PackageError::Io(e.to_string()))?;
304 let path = entry.path();
305 let name = entry.file_name();
306 let rel_path = format!("{}/{}", rel_dir, name.to_string_lossy());
307
308 // Skip hidden files and target directory
309 let name_str = name.to_string_lossy();
310 if name_str.starts_with('.') || name_str == "target" {
311 continue;
312 }
313
314 if path.is_dir() {
315 add_dir_recursive(builder, base_dir, &rel_path)?;
316 } else if path.is_file() {
317 // Only include .lg, .md, and common config files
318 let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
319 if matches!(ext, "lg" | "md" | "toml" | "json") || name_str == "LICENSE" {
320 add_file_to_tar(builder, base_dir, &rel_path)?;
321 }
322 }
323 }
324
325 Ok(())
326}
327
328/// Check if the git working directory has uncommitted changes.
329///
330/// Runs `git status --porcelain` and returns `true` if there is any output,
331/// indicating uncommitted changes (modified, staged, or untracked files).
332///
333/// Returns `false` if:
334/// - The directory is not a git repository
335/// - Git is not available on the system
336/// - The working directory is clean
337///
338/// # Arguments
339///
340/// * `project_dir` - Directory to check (should contain `.git`)
341pub fn is_git_dirty(project_dir: &Path) -> bool {
342 use std::process::Command;
343
344 let output = Command::new("git")
345 .args(["status", "--porcelain"])
346 .current_dir(project_dir)
347 .output();
348
349 match output {
350 Ok(out) if out.status.success() => !out.stdout.is_empty(),
351 _ => false, // Not a git repo or git not available
352 }
353}
354
355// ============== Types ==============
356
357/// User information returned from the registry's `/auth/me` endpoint.
358///
359/// Contains details about the authenticated user, used to confirm
360/// successful login and display user information.
361#[derive(Debug, serde::Deserialize)]
362pub struct UserInfo {
363 /// Unique user identifier in the registry.
364 pub id: String,
365 /// GitHub username (used for login).
366 pub login: String,
367 /// Display name (may differ from login).
368 pub name: Option<String>,
369 /// Whether the user has registry admin privileges.
370 pub is_admin: bool,
371}
372
373/// Metadata submitted when publishing a package.
374///
375/// This information is stored in the registry index and displayed
376/// on the package's registry page.
377#[derive(Debug, serde::Serialize)]
378pub struct PublishMetadata {
379 /// Package name (must match `Largo.toml`).
380 pub name: String,
381 /// Semantic version string (e.g., "1.0.0").
382 pub version: String,
383 /// Short description of the package.
384 pub description: Option<String>,
385 /// URL to the source repository (e.g., GitHub).
386 pub repository: Option<String>,
387 /// URL to the project homepage or documentation.
388 pub homepage: Option<String>,
389 /// SPDX license identifier (e.g., "MIT", "Apache-2.0").
390 pub license: Option<String>,
391 /// Searchable keywords for discovery.
392 pub keywords: Vec<String>,
393 /// Relative path to the entry point file.
394 pub entry_point: String,
395 /// Map of dependency names to version requirements.
396 pub dependencies: std::collections::HashMap<String, String>,
397 /// Full README content (if `README.md` exists).
398 pub readme: Option<String>,
399}
400
401/// Response from a successful publish operation.
402///
403/// Returned by the registry after a package is successfully uploaded
404/// and indexed.
405#[derive(Debug, serde::Deserialize)]
406pub struct PublishResult {
407 /// Whether the publish succeeded.
408 pub success: bool,
409 /// The published package name.
410 pub package: String,
411 /// The published version.
412 pub version: String,
413 /// SHA-256 hash of the uploaded tarball.
414 pub sha256: String,
415 /// Size of the tarball in bytes.
416 pub size: u64,
417}
418
419// ============== Errors ==============
420
421/// Errors that can occur during registry API operations.
422///
423/// Each variant includes a user-friendly error message with guidance
424/// on how to resolve the issue.
425#[derive(Debug)]
426pub enum RegistryError {
427 /// No authentication token was provided or found.
428 NoToken,
429 /// The provided token is invalid or expired (HTTP 401).
430 Unauthorized,
431 /// The server rejected the request (HTTP 403).
432 Forbidden(String),
433 /// The package version already exists in the registry (HTTP 409).
434 VersionExists {
435 /// Package name.
436 name: String,
437 /// Version that already exists.
438 version: String,
439 },
440 /// The package tarball exceeds the size limit (HTTP 413).
441 TooLarge,
442 /// Network or connection error.
443 Network(String),
444 /// The server returned an unexpected error.
445 Server {
446 /// HTTP status code.
447 status: u16,
448 /// Error message from the server.
449 message: String,
450 },
451 /// The package metadata could not be serialized.
452 InvalidPackage(String),
453}
454
455impl std::fmt::Display for RegistryError {
456 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
457 match self {
458 Self::NoToken => write!(
459 f,
460 "No authentication token found.\n\
461 Run 'largo login' or set LOGOS_TOKEN environment variable."
462 ),
463 Self::Unauthorized => write!(
464 f,
465 "Authentication failed. Your token may be invalid or expired.\n\
466 Run 'largo login' to get a new token."
467 ),
468 Self::Forbidden(msg) => write!(f, "Access denied: {}", msg),
469 Self::VersionExists { name, version } => write!(
470 f,
471 "Version {} of package '{}' already exists.\n\
472 Update the version in Largo.toml and try again.",
473 version, name
474 ),
475 Self::TooLarge => write!(f, "Package too large. Maximum size is 10MB."),
476 Self::Network(e) => write!(f, "Network error: {}", e),
477 Self::Server { status, message } => {
478 write!(f, "Registry returned error {}: {}", status, message)
479 }
480 Self::InvalidPackage(e) => write!(f, "Invalid package: {}", e),
481 }
482 }
483}
484
485impl std::error::Error for RegistryError {}
486
487/// Errors that can occur when creating a package tarball.
488#[derive(Debug)]
489pub enum PackageError {
490 /// A required file is missing from the project.
491 MissingFile(String),
492 /// A file system operation failed.
493 Io(String),
494 /// The tar archive could not be created.
495 TarError(String),
496}
497
498impl std::fmt::Display for PackageError {
499 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
500 match self {
501 Self::MissingFile(name) => write!(f, "Missing required file: {}", name),
502 Self::Io(e) => write!(f, "I/O error: {}", e),
503 Self::TarError(e) => write!(f, "Failed to create tarball: {}", e),
504 }
505 }
506}
507
508impl std::error::Error for PackageError {}