commit 1f51e9c575cefc3897e07c53a04d632be1d630bd
parent b89497727278225eb12f243eca82b77affe5d929
Author: triesap <tyson@radroots.org>
Date: Sun, 28 Jun 2026 04:04:05 +0000
packages: generate npm metadata
- add an xtask package metadata generation command
- share README and license validation with the generator
- expose package metadata generation in package scripts
- cover deterministic metadata writes in xtask tests
Diffstat:
6 files changed, 227 insertions(+), 80 deletions(-)
diff --git a/package.json b/package.json
@@ -5,7 +5,8 @@
"scripts": {
"generate:ts": "cargo xtask generate ts",
"generate:wasm": "cargo xtask generate wasm",
- "check": "cargo xtask generate ts && pnpm -r build && cargo xtask check && pnpm -r typecheck",
+ "generate:package-metadata": "cargo xtask generate package-metadata",
+ "check": "cargo xtask generate ts && cargo xtask generate package-metadata && pnpm -r build && cargo xtask check && pnpm -r typecheck",
"coverage": "cargo xtask coverage run",
"build": "pnpm -r build",
"typecheck": "pnpm -r typecheck"
diff --git a/tools/xtask/src/check.rs b/tools/xtask/src/check.rs
@@ -8,6 +8,7 @@ use crate::{
FORBIDDEN_PACKAGE_NAMES, WasmPackageSpec, package_specs, validate_package_matrix,
wasm_package_specs,
},
+ package_metadata::{PACKAGE_FILES, check_package_distribution_metadata, package_description},
ts::generated_header,
wasm_declarations::declaration_files,
};
@@ -17,14 +18,6 @@ const PACKAGE_LICENSE: &str = "MIT OR Apache-2.0";
const PACKAGE_HOMEPAGE: &str = "https://radroots.org";
const PACKAGE_REPOSITORY_URL: &str = "git+https://github.com/radrootslabs/sdk.git";
const PUBLISH_ACCESS: &str = "public";
-const PACKAGE_README_FILE: &str = "README.md";
-const PACKAGE_LICENSE_FILES: [&str; 2] = ["LICENSE-MIT", "LICENSE-APACHE"];
-const PACKAGE_FILES: [&str; 4] = [
- "dist",
- PACKAGE_README_FILE,
- PACKAGE_LICENSE_FILES[0],
- PACKAGE_LICENSE_FILES[1],
-];
pub fn check() -> Result<(), String> {
validate_package_matrix()?;
@@ -251,74 +244,6 @@ fn check_package_json(
Ok(json)
}
-fn check_package_distribution_metadata(
- root: &Path,
- package_dir: &Path,
- package_json_path: &Path,
- json: &serde_json::Value,
-) -> Result<(), String> {
- let package_name = json
- .get("name")
- .and_then(serde_json::Value::as_str)
- .ok_or_else(|| format!("package.json missing name: {}", package_json_path.display()))?;
- let description = package_description(json, package_json_path)?;
- let readme_path = package_dir.join(PACKAGE_README_FILE);
- let expected_readme = package_readme(package_name, description);
- check_text_file(&readme_path, &expected_readme, "stale package README")?;
- for file_name in PACKAGE_LICENSE_FILES {
- let source_path = root.join(file_name);
- let package_path = package_dir.join(file_name);
- let expected = fs::read(&source_path)
- .map_err(|error| format!("failed to read {}: {error}", source_path.display()))?;
- let actual = fs::read(&package_path)
- .map_err(|error| format!("failed to read {}: {error}", package_path.display()))?;
- if actual != expected {
- return Err(format!(
- "stale package license metadata: {}",
- package_path.display()
- ));
- }
- }
- Ok(())
-}
-
-fn package_description<'a>(
- json: &'a serde_json::Value,
- package_json_path: &Path,
-) -> Result<&'a str, String> {
- let description = json
- .get("description")
- .and_then(serde_json::Value::as_str)
- .ok_or_else(|| {
- format!(
- "package.json missing description: {}",
- package_json_path.display()
- )
- })?;
- if description.trim().is_empty() || description.trim() != description {
- return Err(format!(
- "package.json description must be non-empty and trimmed: {}",
- package_json_path.display()
- ));
- }
- Ok(description)
-}
-
-fn package_readme(package_name: &str, description: &str) -> String {
- format!(
- "# {package_name}\n\n{description}\n\nThis package publishes generated ESM JavaScript, TypeScript declarations, and any runtime artifacts from the Radroots SDK build pipeline. Runtime files are distributed from `dist/`; source and provenance metadata are kept outside the npm package payload.\n\n## License\n\nLicensed under either MIT or Apache-2.0, at your option. See `LICENSE-MIT` and `LICENSE-APACHE`.\n"
- )
-}
-
-fn check_text_file(path: &Path, expected: &str, label: &str) -> Result<(), String> {
- let actual = fs::read_to_string(path)
- .map_err(|error| format!("failed to read {}: {error}", path.display()))?;
- if actual != expected {
- return Err(format!("{label}: {}", path.display()));
- }
- Ok(())
-}
-
pub(crate) fn check_wasm_package_surface(root: &Path, spec: WasmPackageSpec) -> Result<(), String> {
let package_dir = root.join(spec.package_dir);
let package_json_path = package_dir.join("package.json");
@@ -730,12 +655,13 @@ mod tests {
use crate::{
output::package_outputs,
package_matrix::{WasmPackageSpec, validate_package_matrix},
+ package_metadata::package_readme,
};
use super::{
check_binding_crate_sources, check_generated_package_artifact_inventory,
check_no_typescript_files, check_package_distribution_metadata, check_package_index,
- check_package_json, check_wasm_package_surface, package_readme,
+ check_package_json, check_wasm_package_surface,
};
#[test]
diff --git a/tools/xtask/src/fs.rs b/tools/xtask/src/fs.rs
@@ -33,6 +33,21 @@ pub fn write_if_changed(path: &Path, contents: &str) -> Result<bool, String> {
Ok(true)
}
+pub fn write_bytes_if_changed(path: &Path, contents: &[u8]) -> Result<bool, String> {
+ if let Ok(existing) = fs::read(path) {
+ if existing == contents {
+ return Ok(false);
+ }
+ }
+ if let Some(parent) = path.parent() {
+ fs::create_dir_all(parent)
+ .map_err(|error| format!("failed to create {}: {error}", parent.display()))?;
+ }
+ fs::write(path, contents)
+ .map_err(|error| format!("failed to write {}: {error}", path.display()))?;
+ Ok(true)
+}
+
#[cfg(test)]
mod tests {
use super::workspace_root;
diff --git a/tools/xtask/src/generate.rs b/tools/xtask/src/generate.rs
@@ -1,4 +1,7 @@
-use crate::{fs::workspace_root, output::package_outputs, package_matrix::validate_package_matrix};
+use crate::{
+ fs::workspace_root, output::package_outputs, package_matrix::validate_package_matrix,
+ package_metadata,
+};
pub fn generate_ts() -> Result<(), String> {
validate_package_matrix()?;
@@ -19,3 +22,9 @@ pub fn generate_ts() -> Result<(), String> {
}
Ok(())
}
+
+pub fn generate_package_metadata() -> Result<(), String> {
+ validate_package_matrix()?;
+ let root = workspace_root()?;
+ package_metadata::generate_package_metadata(&root)
+}
diff --git a/tools/xtask/src/main.rs b/tools/xtask/src/main.rs
@@ -9,6 +9,7 @@ mod generate;
mod manifest;
mod output;
mod package_matrix;
+mod package_metadata;
mod ts;
mod wasm;
mod wasm_declarations;
@@ -16,6 +17,7 @@ mod wasm_declarations;
enum CommandAction<'a> {
GenerateTs,
GenerateWasm(&'a [String]),
+ GeneratePackageMetadata,
Coverage(&'a [String]),
Check,
}
@@ -32,6 +34,7 @@ fn run(args: impl IntoIterator<Item = String>) -> Result<(), String> {
match command_action(&args)? {
CommandAction::GenerateTs => generate::generate_ts(),
CommandAction::GenerateWasm(rest) => wasm::generate(rest),
+ CommandAction::GeneratePackageMetadata => generate::generate_package_metadata(),
CommandAction::Coverage(rest) => coverage::run(rest),
CommandAction::Check => check::check(),
}
@@ -45,6 +48,9 @@ fn command_action(args: &[String]) -> Result<CommandAction<'_>, String> {
[command, target, rest @ ..] if command == "generate" && target == "wasm" => {
Ok(CommandAction::GenerateWasm(rest))
}
+ [command, target] if command == "generate" && target == "package-metadata" => {
+ Ok(CommandAction::GeneratePackageMetadata)
+ }
[command, rest @ ..] if command == "coverage" => Ok(CommandAction::Coverage(rest)),
[command] if command == "check" => Ok(CommandAction::Check),
[] => Err(usage()),
@@ -53,7 +59,7 @@ fn command_action(args: &[String]) -> Result<CommandAction<'_>, String> {
}
fn usage() -> String {
- "usage: cargo xtask generate ts | cargo xtask generate wasm [--package <key>] | cargo xtask check | cargo xtask coverage run"
+ "usage: cargo xtask generate ts | cargo xtask generate wasm [--package <key>] | cargo xtask generate package-metadata | cargo xtask check | cargo xtask coverage run"
.to_owned()
}
@@ -80,6 +86,15 @@ mod tests {
}
#[test]
+ fn accepts_generate_package_metadata() {
+ let args = ["generate".to_owned(), "package-metadata".to_owned()];
+ assert!(matches!(
+ command_action(&args).expect("action"),
+ CommandAction::GeneratePackageMetadata
+ ));
+ }
+
+ #[test]
fn accepts_check() {
let args = ["check".to_owned()];
assert!(matches!(
diff --git a/tools/xtask/src/package_metadata.rs b/tools/xtask/src/package_metadata.rs
@@ -0,0 +1,181 @@
+use std::{fs, path::Path};
+
+use crate::{
+ fs::{write_bytes_if_changed, write_if_changed},
+ package_matrix::{package_specs, wasm_package_specs},
+};
+
+pub(crate) const PACKAGE_README_FILE: &str = "README.md";
+pub(crate) const PACKAGE_LICENSE_FILES: [&str; 2] = ["LICENSE-MIT", "LICENSE-APACHE"];
+pub(crate) const PACKAGE_FILES: [&str; 4] = [
+ "dist",
+ PACKAGE_README_FILE,
+ PACKAGE_LICENSE_FILES[0],
+ PACKAGE_LICENSE_FILES[1],
+];
+
+pub(crate) fn generate_package_metadata(root: &Path) -> Result<(), String> {
+ for spec in package_specs() {
+ write_package_metadata(root, &root.join(spec.package_dir))?;
+ println!("generated package metadata {}", spec.package_name);
+ }
+ for spec in wasm_package_specs() {
+ write_package_metadata(root, &root.join(spec.package_dir))?;
+ println!("generated package metadata {}", spec.package_name);
+ }
+ Ok(())
+}
+
+pub(crate) fn check_package_distribution_metadata(
+ root: &Path,
+ package_dir: &Path,
+ package_json_path: &Path,
+ json: &serde_json::Value,
+) -> Result<(), String> {
+ let package_name = package_name(json, package_json_path)?;
+ let description = package_description(json, package_json_path)?;
+ let readme_path = package_dir.join(PACKAGE_README_FILE);
+ let expected_readme = package_readme(package_name, description);
+ check_text_file(&readme_path, &expected_readme, "stale package README")?;
+ for file_name in PACKAGE_LICENSE_FILES {
+ let source_path = root.join(file_name);
+ let package_path = package_dir.join(file_name);
+ let expected = fs::read(&source_path)
+ .map_err(|error| format!("failed to read {}: {error}", source_path.display()))?;
+ let actual = fs::read(&package_path)
+ .map_err(|error| format!("failed to read {}: {error}", package_path.display()))?;
+ if actual != expected {
+ return Err(format!(
+ "stale package license metadata: {}",
+ package_path.display()
+ ));
+ }
+ }
+ Ok(())
+}
+
+pub(crate) fn package_description<'a>(
+ json: &'a serde_json::Value,
+ package_json_path: &Path,
+) -> Result<&'a str, String> {
+ let description = json
+ .get("description")
+ .and_then(serde_json::Value::as_str)
+ .ok_or_else(|| {
+ format!(
+ "package.json missing description: {}",
+ package_json_path.display()
+ )
+ })?;
+ if description.trim().is_empty() || description.trim() != description {
+ return Err(format!(
+ "package.json description must be non-empty and trimmed: {}",
+ package_json_path.display()
+ ));
+ }
+ Ok(description)
+}
+
+pub(crate) fn package_readme(package_name: &str, description: &str) -> String {
+ format!(
+ "# {package_name}\n\n{description}\n\nThis package publishes generated ESM JavaScript, TypeScript declarations, and any runtime artifacts from the Radroots SDK build pipeline. Runtime files are distributed from `dist/`; source and provenance metadata are kept outside the npm package payload.\n\n## License\n\nLicensed under either MIT or Apache-2.0, at your option. See `LICENSE-MIT` and `LICENSE-APACHE`.\n"
+ )
+}
+
+fn write_package_metadata(root: &Path, package_dir: &Path) -> Result<bool, String> {
+ let package_json_path = package_dir.join("package.json");
+ let json = read_package_json(&package_json_path)?;
+ let package_name = package_name(&json, &package_json_path)?;
+ let description = package_description(&json, &package_json_path)?;
+ let mut changed = write_if_changed(
+ &package_dir.join(PACKAGE_README_FILE),
+ &package_readme(package_name, description),
+ )?;
+ for file_name in PACKAGE_LICENSE_FILES {
+ let source_path = root.join(file_name);
+ let contents = fs::read(&source_path)
+ .map_err(|error| format!("failed to read {}: {error}", source_path.display()))?;
+ changed |= write_bytes_if_changed(&package_dir.join(file_name), &contents)?;
+ }
+ Ok(changed)
+}
+
+fn read_package_json(path: &Path) -> Result<serde_json::Value, String> {
+ let raw = fs::read_to_string(path)
+ .map_err(|error| format!("failed to read {}: {error}", path.display()))?;
+ serde_json::from_str::<serde_json::Value>(&raw)
+ .map_err(|error| format!("failed to parse {}: {error}", path.display()))
+}
+
+fn package_name<'a>(
+ json: &'a serde_json::Value,
+ package_json_path: &Path,
+) -> Result<&'a str, String> {
+ json.get("name")
+ .and_then(serde_json::Value::as_str)
+ .ok_or_else(|| format!("package.json missing name: {}", package_json_path.display()))
+}
+
+fn check_text_file(path: &Path, expected: &str, label: &str) -> Result<(), String> {
+ let actual = fs::read_to_string(path)
+ .map_err(|error| format!("failed to read {}: {error}", path.display()))?;
+ if actual != expected {
+ return Err(format!("{label}: {}", path.display()));
+ }
+ Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+ use std::{
+ fs,
+ path::PathBuf,
+ time::{SystemTime, UNIX_EPOCH},
+ };
+
+ use super::{package_readme, write_package_metadata};
+
+ #[test]
+ fn package_metadata_generation_is_deterministic() {
+ let root = test_root("metadata_generation");
+ let package_dir = root.join("packages/example");
+ fs::create_dir_all(&package_dir).expect("create package");
+ fs::write(root.join("LICENSE-MIT"), "MIT license\n").expect("write MIT license");
+ fs::write(root.join("LICENSE-APACHE"), "Apache license\n").expect("write Apache license");
+ fs::write(
+ package_dir.join("package.json"),
+ r#"{
+ "name": "@radroots/example",
+ "description": "Example package"
+}"#,
+ )
+ .expect("write package json");
+
+ assert!(write_package_metadata(&root, &package_dir).expect("first generation"));
+ assert_eq!(
+ fs::read_to_string(package_dir.join("README.md")).expect("read README"),
+ package_readme("@radroots/example", "Example package")
+ );
+ assert_eq!(
+ fs::read_to_string(package_dir.join("LICENSE-MIT")).expect("read MIT"),
+ "MIT license\n"
+ );
+ assert!(!write_package_metadata(&root, &package_dir).expect("second generation"));
+
+ let _ = fs::remove_dir_all(root);
+ }
+
+ fn test_root(name: &str) -> PathBuf {
+ let stamp = SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .expect("system time after epoch")
+ .as_nanos();
+ let root = std::env::temp_dir().join(format!(
+ "radroots_sdk_xtask_package_metadata_{name}_{}_{}",
+ std::process::id(),
+ stamp
+ ));
+ let _ = fs::remove_dir_all(&root);
+ root
+ }
+}